diff options
Diffstat (limited to 'app/javascript/flavours/glitch/features')
38 files changed, 433 insertions, 208 deletions
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js index 9c80a470b..3d6eeb06a 100644 --- a/app/javascript/flavours/glitch/features/account/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js @@ -2,9 +2,9 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; -import { Link } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl'; -import { me } from 'flavours/glitch/util/initial_state'; +import { me, isStaff } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' }, @@ -25,6 +25,7 @@ const messages = defineMessages({ showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, }); @injectIntl @@ -51,6 +52,13 @@ export default class ActionBar extends React.PureComponent { }); } + isStatusesPageActive = (match, location) => { + if (!match) { + return false; + } + return !location.pathname.match(/\/(followers|following)\/?$/); + } + render () { const { account, intl } = this.props; @@ -120,6 +128,11 @@ export default class ActionBar extends React.PureComponent { } } + if (account.get('id') !== me && isStaff) { + menu.push(null); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` }); + } + return ( <div> {extraInfo} @@ -130,20 +143,20 @@ export default class ActionBar extends React.PureComponent { </div> <div className='account__action-bar-links'> - <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> + <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}> <FormattedMessage id='account.posts' defaultMessage='Posts' /> <strong><FormattedNumber value={account.get('statuses_count')} /></strong> - </Link> + </NavLink> - <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> + <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}> <FormattedMessage id='account.follows' defaultMessage='Follows' /> <strong><FormattedNumber value={account.get('following_count')} /></strong> - </Link> + </NavLink> - <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> + <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}> <FormattedMessage id='account.followers' defaultMessage='Followers' /> <strong><FormattedNumber value={account.get('followers_count')} /></strong> - </Link> + </NavLink> </div> </div> </div> diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js index eda0d637e..f0d36947d 100644 --- a/app/javascript/flavours/glitch/features/account/components/header.js +++ b/app/javascript/flavours/glitch/features/account/components/header.js @@ -15,8 +15,19 @@ const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, + edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + link_verified_on: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, }); +const dateFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour12: false, + hour: '2-digit', + minute: '2-digit', +}; + @injectIntl export default class Header extends ImmutablePureComponent { @@ -27,6 +38,10 @@ export default class Header extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; + openEditProfile = () => { + window.open('/settings/profile', '_blank'); + } + render () { const { account, intl } = this.props; @@ -77,6 +92,12 @@ export default class Header extends ImmutablePureComponent { </div> ); } + } else { + actionBtn = ( + <div className='account--action-button'> + <IconButton size={26} icon='pencil' title={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} /> + </div> + ); } if (account.get('moved') && !account.getIn(['relationship', 'following'])) { @@ -111,7 +132,9 @@ export default class Header extends ImmutablePureComponent { {fields.map((pair, i) => ( <dl key={i}> <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} /> - <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} /> + <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}> + {pair.get('verified_at') && <span title={intl.formatMessage(messages.link_verified_on, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><i className='fa fa-check verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} /> + </dd> </dl> ))} </div> diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js index 20ba0a1b1..2216f9153 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/index.js +++ b/app/javascript/flavours/glitch/features/account_timeline/index.js @@ -60,10 +60,6 @@ export default class AccountTimeline extends ImmutablePureComponent { this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props; @@ -87,7 +83,6 @@ export default class AccountTimeline extends ImmutablePureComponent { isLoading={isLoading} hasMore={hasMore} onLoadMore={this.handleLoadMore} - shouldUpdateScroll={this.shouldUpdateScroll} /> </Column> ); diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js index f1b4f947e..9468ad81d 100644 --- a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js +++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js @@ -66,10 +66,6 @@ export default class Bookmarks extends ImmutablePureComponent { this.props.dispatch(expandBookmarkedStatuses()); }, 300, { leading: true }) - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const pinned = !!columnId; @@ -91,7 +87,6 @@ export default class Bookmarks extends ImmutablePureComponent { trackScroll={!pinned} statusIds={statusIds} scrollKey={`bookmarked_statuses-${columnId}`} - shouldUpdateScroll={this.shouldUpdateScroll} hasMore={hasMore} isLoading={isLoading} onLoadMore={this.handleLoadMore} diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js index 5bd9d037c..ddcca2dc0 100644 --- a/app/javascript/flavours/glitch/features/community_timeline/index.js +++ b/app/javascript/flavours/glitch/features/community_timeline/index.js @@ -71,10 +71,6 @@ export default class CommunityTimeline extends React.PureComponent { this.props.dispatch(expandCommunityTimeline({ maxId })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, hasUnread, columnId, multiColumn } = this.props; const pinned = !!columnId; @@ -97,7 +93,6 @@ export default class CommunityTimeline extends React.PureComponent { <StatusListContainer trackScroll={!pinned} scrollKey={`community_timeline-${columnId}`} - shouldUpdateScroll={this.shouldUpdateScroll} timelineId='community' onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} diff --git a/app/javascript/flavours/glitch/features/composer/index.js b/app/javascript/flavours/glitch/features/composer/index.js index bc409f0a3..257797047 100644 --- a/app/javascript/flavours/glitch/features/composer/index.js +++ b/app/javascript/flavours/glitch/features/composer/index.js @@ -102,6 +102,7 @@ function mapStateToProps (state) { anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, spoilersAlwaysOn: spoilersAlwaysOn, mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']), + preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']), }; }; @@ -146,7 +147,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onMount() { dispatch(mountCompose()); }, - onOpenActionModal(props) { + onOpenActionsModal(props) { dispatch(openModal('ACTIONS', props)); }, onOpenDoodleModal() { @@ -242,7 +243,7 @@ const handlers = { } // Submit disabled: - if (isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia)) { + if (isSubmitting || isUploading || (!text.trim().length && !anyMedia)) { return; } @@ -328,13 +329,14 @@ class Composer extends React.Component { isSubmitting, preselectDate, text, + preselectOnReply, } = this.props; let selectionEnd, selectionStart; // Caret/selection handling. if (focusDate !== prevProps.focusDate) { switch (true) { - case preselectDate !== prevProps.preselectDate: + case preselectDate !== prevProps.preselectDate && preselectOnReply: selectionStart = text.search(/\s/) + 1; selectionEnd = text.length; break; @@ -347,6 +349,7 @@ class Composer extends React.Component { if (textarea) { textarea.setSelectionRange(selectionStart, selectionEnd); textarea.focus(); + textarea.scrollIntoView(); } // Refocuses the textarea after submitting. @@ -415,7 +418,7 @@ class Composer extends React.Component { spoilersAlwaysOn, } = this.props; - let disabledButton = isSubmitting || isUploading || (!!text.length && !text.trim().length && !anyMedia); + let disabledButton = isSubmitting || isUploading || (!text.trim().length && !anyMedia); return ( <div className='composer'> @@ -485,8 +488,8 @@ class Composer extends React.Component { onUpload={onUpload} privacy={privacy} resetFileKey={resetFileKey} - sensitive={sensitive} - spoiler={spoiler} + sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} + spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} /> <ComposerPublisher countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} @@ -533,6 +536,7 @@ Composer.propTypes = { anyMedia: PropTypes.bool, spoilersAlwaysOn: PropTypes.bool, mediaDescriptionConfirmation: PropTypes.bool, + preselectOnReply: PropTypes.bool, // Dispatch props. onCancelReply: PropTypes.func, diff --git a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js index 331692398..1b7ae8904 100644 --- a/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js +++ b/app/javascript/flavours/glitch/features/composer/textarea/suggestions/item/index.js @@ -11,7 +11,7 @@ import { unicodeMapping } from 'flavours/glitch/util/emoji'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; // Gets our asset host from the environment, if available. -const assetHost = ((process || {}).env || {}).CDN_HOST || ''; +const assetHost = process.env.CDN_HOST || ''; // Handlers. const handlers = { diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js index 949c85dce..dc7e0534d 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/index.js +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js @@ -71,10 +71,6 @@ export default class DirectTimeline extends React.PureComponent { this.props.dispatch(expandDirectTimeline({ maxId })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, hasUnread, columnId, multiColumn } = this.props; const pinned = !!columnId; @@ -97,7 +93,6 @@ export default class DirectTimeline extends React.PureComponent { <StatusListContainer trackScroll={!pinned} scrollKey={`direct_timeline-${columnId}`} - shouldUpdateScroll={this.shouldUpdateScroll} timelineId='direct' onLoadMore={this.handleLoadMore} emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />} diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js index 8b023e0bc..3b29e2a26 100644 --- a/app/javascript/flavours/glitch/features/domain_blocks/index.js +++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js @@ -40,10 +40,6 @@ export default class Blocks extends ImmutablePureComponent { this.props.dispatch(expandDomainBlocks()); }, 300, { leading: true }); - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, domains } = this.props; @@ -58,7 +54,7 @@ export default class Blocks extends ImmutablePureComponent { return ( <Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore} shouldUpdateScroll={this.shouldUpdateScroll}> + <ScrollableList scrollKey='domain_blocks' onLoadMore={this.handleLoadMore}> {domains.map(domain => <DomainContainer key={domain} domain={domain} /> )} diff --git a/app/javascript/flavours/glitch/features/drawer/header/index.js b/app/javascript/flavours/glitch/features/drawer/header/index.js index deec42435..7fefd32c9 100644 --- a/app/javascript/flavours/glitch/features/drawer/header/index.js +++ b/app/javascript/flavours/glitch/features/drawer/header/index.js @@ -46,6 +46,8 @@ const messages = defineMessages({ // The component. export default function DrawerHeader ({ columns, + unreadNotifications, + showNotificationsBadge, intl, onSettingsClick, }) { @@ -77,7 +79,12 @@ export default function DrawerHeader ({ aria-label={intl.formatMessage(messages.notifications)} title={intl.formatMessage(messages.notifications)} to='/notifications' - ><Icon icon='bell' /></Link> + > + <span className='icon-badge-wrapper'> + <Icon icon='bell' /> + { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />} + </span> + </Link> ))} {renderForColumn('COMMUNITY', ( <Link @@ -112,6 +119,8 @@ export default function DrawerHeader ({ // Props. DrawerHeader.propTypes = { columns: ImmutablePropTypes.list, + unreadNotifications: PropTypes.number, + showNotificationsBadge: PropTypes.bool, intl: PropTypes.object, onSettingsClick: PropTypes.func, }; diff --git a/app/javascript/flavours/glitch/features/drawer/index.js b/app/javascript/flavours/glitch/features/drawer/index.js index 497b7d0e4..038a2513e 100644 --- a/app/javascript/flavours/glitch/features/drawer/index.js +++ b/app/javascript/flavours/glitch/features/drawer/index.js @@ -40,6 +40,8 @@ const mapStateToProps = state => ({ searchHidden: state.getIn(['search', 'hidden']), searchValue: state.getIn(['search', 'value']), submitted: state.getIn(['search', 'submitted']), + unreadNotifications: state.getIn(['notifications', 'unread']), + showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), }); // Dispatch mapping. @@ -93,6 +95,8 @@ class Drawer extends React.Component { searchValue, submitted, isSearchPage, + unreadNotifications, + showNotificationsBadge, } = this.props; const computedClass = classNames('drawer', `mbstobon-${elefriend}`); @@ -102,6 +106,8 @@ class Drawer extends React.Component { {multiColumn ? ( <DrawerHeader columns={columns} + unreadNotifications={unreadNotifications} + showNotificationsBadge={showNotificationsBadge} intl={intl} onSettingsClick={onOpenSettings} /> @@ -145,6 +151,8 @@ Drawer.propTypes = { searchHidden: PropTypes.bool, searchValue: PropTypes.string, submitted: PropTypes.bool, + unreadNotifications: PropTypes.number, + showNotificationsBadge: PropTypes.bool, // Dispatch props. onChange: PropTypes.func, diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index d22a50848..a78117971 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -340,6 +340,7 @@ class EmojiPickerMenu extends React.PureComponent { skin={skinTone} showPreview={false} backgroundImageFn={backgroundImageFn} + autoFocus emojiTooltip /> diff --git a/app/javascript/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js index 923d4b24f..32bf4e71a 100644 --- a/app/javascript/flavours/glitch/features/favourited_statuses/index.js +++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js @@ -66,10 +66,6 @@ export default class Favourites extends ImmutablePureComponent { this.props.dispatch(expandFavouritedStatuses()); }, 300, { leading: true }) - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props; const pinned = !!columnId; @@ -91,7 +87,6 @@ export default class Favourites extends ImmutablePureComponent { trackScroll={!pinned} statusIds={statusIds} scrollKey={`favourited_statuses-${columnId}`} - shouldUpdateScroll={this.shouldUpdateScroll} hasMore={hasMore} isLoading={isLoading} onLoadMore={this.handleLoadMore} diff --git a/app/javascript/flavours/glitch/features/favourites/index.js b/app/javascript/flavours/glitch/features/favourites/index.js index 055a15ccb..cf8b31eb3 100644 --- a/app/javascript/flavours/glitch/features/favourites/index.js +++ b/app/javascript/flavours/glitch/features/favourites/index.js @@ -33,6 +33,10 @@ export default class Favourites extends ImmutablePureComponent { } } + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen); + } + render () { const { accountIds } = this.props; @@ -48,7 +52,7 @@ export default class Favourites extends ImmutablePureComponent { <Column> <ColumnBackButton /> - <ScrollContainer scrollKey='favourites'> + <ScrollContainer scrollKey='favourites' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable'> {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} </div> diff --git a/app/javascript/flavours/glitch/features/follow_requests/index.js b/app/javascript/flavours/glitch/features/follow_requests/index.js index 04ff3f111..1e4633984 100644 --- a/app/javascript/flavours/glitch/features/follow_requests/index.js +++ b/app/javascript/flavours/glitch/features/follow_requests/index.js @@ -42,6 +42,10 @@ export default class FollowRequests extends ImmutablePureComponent { } } + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen); + } + render () { const { intl, accountIds } = this.props; @@ -57,7 +61,7 @@ export default class FollowRequests extends ImmutablePureComponent { <Column name='follow-requests' icon='users' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollContainer scrollKey='follow_requests'> + <ScrollContainer scrollKey='follow_requests' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable' onScroll={this.handleScroll}> {accountIds.map(id => <AccountAuthorizeContainer key={id} id={id} /> diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js index c42e0386c..cdde1775c 100644 --- a/app/javascript/flavours/glitch/features/followers/index.js +++ b/app/javascript/flavours/glitch/features/followers/index.js @@ -56,6 +56,10 @@ export default class Followers extends ImmutablePureComponent { this.props.dispatch(expandFollowers(this.props.params.accountId)); } + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen); + } + render () { const { accountIds, hasMore } = this.props; @@ -77,7 +81,7 @@ export default class Followers extends ImmutablePureComponent { <Column> <ColumnBackButton /> - <ScrollContainer scrollKey='followers'> + <ScrollContainer scrollKey='followers' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable' onScroll={this.handleScroll}> <div className='followers'> <HeaderContainer accountId={this.props.params.accountId} hideTabs /> diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js index f710456d5..311fabb63 100644 --- a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js @@ -82,10 +82,6 @@ export default class HashtagTimeline extends React.PureComponent { this.props.dispatch(expandHashtagTimeline(this.props.params.id, { maxId })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { hasUnread, columnId, multiColumn } = this.props; const { id } = this.props.params; @@ -110,7 +106,6 @@ export default class HashtagTimeline extends React.PureComponent { scrollKey={`hashtag_timeline-${columnId}`} timelineId={`hashtag:${id}`} onLoadMore={this.handleLoadMore} - shouldUpdateScroll={this.shouldUpdateScroll} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> </Column> diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js index 0c1040290..a992b1ffc 100644 --- a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js @@ -10,6 +10,7 @@ import LocalSettingsNavigationItem from './item'; const messages = defineMessages({ general: { id: 'settings.general', defaultMessage: 'General' }, + compose: { id: 'settings.compose_box_opts', defaultMessage: 'Compose box options' }, content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' }, collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' }, media: { id: 'settings.media', defaultMessage: 'Media' }, @@ -43,31 +44,37 @@ export default class LocalSettingsNavigation extends React.PureComponent { active={index === 1} index={1} onNavigate={onNavigate} - title={intl.formatMessage(messages.content_warnings)} + title={intl.formatMessage(messages.compose)} /> <LocalSettingsNavigationItem active={index === 2} index={2} onNavigate={onNavigate} - title={intl.formatMessage(messages.collapsed)} + title={intl.formatMessage(messages.content_warnings)} /> <LocalSettingsNavigationItem active={index === 3} index={3} onNavigate={onNavigate} - title={intl.formatMessage(messages.media)} + title={intl.formatMessage(messages.collapsed)} /> <LocalSettingsNavigationItem active={index === 4} - href='/settings/preferences' index={4} + onNavigate={onNavigate} + title={intl.formatMessage(messages.media)} + /> + <LocalSettingsNavigationItem + active={index === 5} + href='/settings/preferences' + index={5} icon='cog' title={intl.formatMessage(messages.preferences)} /> <LocalSettingsNavigationItem - active={index === 5} + active={index === 6} className='close' - index={5} + index={6} onNavigate={onClose} title={intl.formatMessage(messages.close)} /> diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js index 0db49ba5d..5b4e0cb01 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -43,6 +43,25 @@ export default class LocalSettingsPage extends React.PureComponent { <FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' /> </LocalSettingsPageItem> <section> + <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'tab_badge']} + id='mastodon-settings--notifications-tab_badge' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.tab_badge' defaultMessage="Display a badge for unread notifications if the notifications column isn't open" /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'favicon_badge']} + id='mastodon-settings--notifications-favicon_badge' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Display unread notifications count in the favicon' /> + </LocalSettingsPageItem> + </section> + <section> <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2> <LocalSettingsPageItem settings={settings} @@ -74,53 +93,63 @@ export default class LocalSettingsPage extends React.PureComponent { <FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' /> </LocalSettingsPageItem> </section> - <section> - <h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2> - <LocalSettingsPageItem - settings={settings} - item={['always_show_spoilers_field']} - id='mastodon-settings--always_show_spoilers_field' - onChange={onChange} - > - <FormattedMessage id='settings.always_show_spoilers_field' defaultMessage='Always enable the Content Warning field' /> - </LocalSettingsPageItem> - <LocalSettingsPageItem - settings={settings} - item={['confirm_missing_media_description']} - id='mastodon-settings--confirm_missing_media_description' - onChange={onChange} - > - <FormattedMessage id='settings.confirm_missing_media_description' defaultMessage='Show confirmation dialog before sending toots lacking media descriptions' /> - </LocalSettingsPageItem> - <LocalSettingsPageItem - settings={settings} - item={['side_arm']} - id='mastodon-settings--side_arm' - options={[ - { value: 'none', message: intl.formatMessage(messages.side_arm_none) }, - { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) }, - { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) }, - { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) }, - { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) }, - ]} - onChange={onChange} - > - <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' /> - </LocalSettingsPageItem> - <LocalSettingsPageItem - settings={settings} - item={['side_arm_reply_mode']} - id='mastodon-settings--side_arm_reply_mode' - options={[ - { value: 'keep', message: intl.formatMessage(messages.side_arm_keep) }, - { value: 'copy', message: intl.formatMessage(messages.side_arm_copy) }, - { value: 'restrict', message: intl.formatMessage(messages.side_arm_restrict) }, - ]} - onChange={onChange} - > - <FormattedMessage id='settings.side_arm_reply_mode' defaultMessage='When replying to a toot:' /> - </LocalSettingsPageItem> - </section> + </div> + ), + ({ intl, onChange, settings }) => ( + <div className='glitch local-settings__page compose_box_opts'> + <h1><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h1> + <LocalSettingsPageItem + settings={settings} + item={['always_show_spoilers_field']} + id='mastodon-settings--always_show_spoilers_field' + onChange={onChange} + > + <FormattedMessage id='settings.always_show_spoilers_field' defaultMessage='Always enable the Content Warning field' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['preselect_on_reply']} + id='mastodon-settings--preselect_on_reply' + onChange={onChange} + > + <FormattedMessage id='settings.preselect_on_reply' defaultMessage='Pre-select usernames past the first when replying to a toot with multiple participants' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['confirm_missing_media_description']} + id='mastodon-settings--confirm_missing_media_description' + onChange={onChange} + > + <FormattedMessage id='settings.confirm_missing_media_description' defaultMessage='Show confirmation dialog before sending toots lacking media descriptions' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['side_arm']} + id='mastodon-settings--side_arm' + options={[ + { value: 'none', message: intl.formatMessage(messages.side_arm_none) }, + { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) }, + { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) }, + { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) }, + { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['side_arm_reply_mode']} + id='mastodon-settings--side_arm_reply_mode' + options={[ + { value: 'keep', message: intl.formatMessage(messages.side_arm_keep) }, + { value: 'copy', message: intl.formatMessage(messages.side_arm_copy) }, + { value: 'restrict', message: intl.formatMessage(messages.side_arm_restrict) }, + ]} + onChange={onChange} + > + <FormattedMessage id='settings.side_arm_reply_mode' defaultMessage='When replying to a toot:' /> + </LocalSettingsPageItem> </div> ), ({ intl, onChange, settings }) => ( diff --git a/app/javascript/flavours/glitch/features/mutes/index.js b/app/javascript/flavours/glitch/features/mutes/index.js index 87517eec9..d94c1d8ad 100644 --- a/app/javascript/flavours/glitch/features/mutes/index.js +++ b/app/javascript/flavours/glitch/features/mutes/index.js @@ -42,6 +42,10 @@ export default class Mutes extends ImmutablePureComponent { } } + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen); + } + render () { const { intl, accountIds } = this.props; @@ -56,7 +60,7 @@ export default class Mutes extends ImmutablePureComponent { return ( <Column name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> - <ScrollContainer scrollKey='mutes'> + <ScrollContainer scrollKey='mutes' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable mutes' onScroll={this.handleScroll}> {accountIds.map(id => <AccountContainer key={id} id={id} /> diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 4c05b24fe..13ed26865 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -8,6 +8,8 @@ import { enterNotificationClearingMode, expandNotifications, scrollTopNotifications, + mountNotifications, + unmountNotifications, } from 'flavours/glitch/actions/notifications'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; import NotificationContainer from './containers/notification_container'; @@ -42,6 +44,12 @@ const mapDispatchToProps = dispatch => ({ onEnterCleaningMode(yes) { dispatch(enterNotificationClearingMode(yes)); }, + onMount() { + dispatch(mountNotifications()); + }, + onUnmount() { + dispatch(unmountNotifications()); + }, dispatch, }); @@ -62,6 +70,8 @@ export default class Notifications extends React.PureComponent { localSettings: ImmutablePropTypes.map, notifCleaningActive: PropTypes.bool, onEnterCleaningMode: PropTypes.func, + onMount: PropTypes.func, + onUnmount: PropTypes.func, }; static defaultProps = { @@ -126,6 +136,20 @@ export default class Notifications extends React.PureComponent { } } + componentDidMount () { + const { onMount } = this.props; + if (onMount) { + onMount(); + } + } + + componentWillUnmount () { + const { onUnmount } = this.props; + if (onUnmount) { + onUnmount(); + } + } + render () { const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props; const pinned = !!columnId; diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.js b/app/javascript/flavours/glitch/features/pinned_statuses/index.js index e7fa7ac0d..f56d70176 100644 --- a/app/javascript/flavours/glitch/features/pinned_statuses/index.js +++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js @@ -41,10 +41,6 @@ export default class PinnedStatuses extends ImmutablePureComponent { this.column = c; } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, statusIds, hasMore } = this.props; @@ -54,7 +50,6 @@ export default class PinnedStatuses extends ImmutablePureComponent { <StatusList statusIds={statusIds} scrollKey='pinned_statuses' - shouldUpdateScroll={this.shouldUpdateScroll} hasMore={hasMore} /> </Column> diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js index af86b22ac..53f2836f1 100644 --- a/app/javascript/flavours/glitch/features/public_timeline/index.js +++ b/app/javascript/flavours/glitch/features/public_timeline/index.js @@ -71,10 +71,6 @@ export default class PublicTimeline extends React.PureComponent { this.props.dispatch(expandPublicTimeline({ maxId })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl, columnId, hasUnread, multiColumn } = this.props; const pinned = !!columnId; @@ -99,7 +95,6 @@ export default class PublicTimeline extends React.PureComponent { onLoadMore={this.handleLoadMore} trackScroll={!pinned} scrollKey={`public_timeline-${columnId}`} - shouldUpdateScroll={this.shouldUpdateScroll} emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> </Column> diff --git a/app/javascript/flavours/glitch/features/reblogs/index.js b/app/javascript/flavours/glitch/features/reblogs/index.js index 25b792b39..c0a65d1de 100644 --- a/app/javascript/flavours/glitch/features/reblogs/index.js +++ b/app/javascript/flavours/glitch/features/reblogs/index.js @@ -33,6 +33,10 @@ export default class Reblogs extends ImmutablePureComponent { } } + shouldUpdateScroll = (prevRouterProps, { location }) => { + return !(location.state && location.state.mastodonModalOpen); + } + render () { const { accountIds } = this.props; @@ -48,7 +52,7 @@ export default class Reblogs extends ImmutablePureComponent { <Column> <ColumnBackButton /> - <ScrollContainer scrollKey='reblogs'> + <ScrollContainer scrollKey='reblogs' shouldUpdateScroll={this.shouldUpdateScroll}> <div className='scrollable reblogs'> {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)} </div> diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js index d4ef3ce48..2b67e836a 100644 --- a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js +++ b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js @@ -47,10 +47,6 @@ export default class CommunityTimeline extends React.PureComponent { this.props.dispatch(expandCommunityTimeline({ maxId })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { intl } = this.props; @@ -66,7 +62,6 @@ export default class CommunityTimeline extends React.PureComponent { timelineId='community' onLoadMore={this.handleLoadMore} scrollKey='standalone_public_timeline' - shouldUpdateScroll={this.shouldUpdateScroll} trackScroll={false} /> </Column> diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js index d2b1971ec..dc02f1c91 100644 --- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js +++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js @@ -41,10 +41,6 @@ export default class HashtagTimeline extends React.PureComponent { this.props.dispatch(expandHashtagTimeline(this.props.hashtag, { maxId })); } - shouldUpdateScroll = (prevRouterProps, { location }) => { - return !(location.state && location.state.mastodonModalOpen) - } - render () { const { hashtag } = this.props; @@ -59,7 +55,6 @@ export default class HashtagTimeline extends React.PureComponent { <StatusListContainer trackScroll={false} scrollKey='standalone_hashtag_timeline' - shouldUpdateScroll={this.shouldUpdateScroll} timelineId={`hashtag:${hashtag}`} onLoadMore={this.handleLoadMore} /> diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js index 413833a79..009aa49eb 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.js +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js @@ -4,7 +4,7 @@ import IconButton from 'flavours/glitch/components/icon_button'; import ImmutablePropTypes from 'react-immutable-proptypes'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; -import { me } from 'flavours/glitch/util/initial_state'; +import { me, isStaff } from 'flavours/glitch/util/initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -26,6 +26,8 @@ const messages = defineMessages({ pin: { id: 'status.pin', defaultMessage: 'Pin on profile' }, unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' }, embed: { id: 'status.embed', defaultMessage: 'Embed' }, + admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' }, }); @injectIntl @@ -146,6 +148,11 @@ export default class ActionBar extends React.PureComponent { 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 (isStaff) { + menu.push(null); + menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); + menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` }); + } } const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && ( diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index 680bf63ab..b52f3c4fa 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -20,6 +20,39 @@ const getHostname = url => { return parser.hostname; }; +const trim = (text, len) => { + const cut = text.indexOf(' ', len); + + if (cut === -1) { + return text; + } + + return text.substring(0, cut) + (text.length > len ? '…' : ''); +}; + +const domParser = new DOMParser(); + +const addAutoPlay = html => { + const document = domParser.parseFromString(html, 'text/html').documentElement; + const iframe = document.querySelector('iframe'); + + if (iframe) { + if (iframe.src.indexOf('?') !== -1) { + iframe.src += '&'; + } else { + iframe.src += '?'; + } + + iframe.src += 'autoplay=1&auto_play=1'; + + // DOM parser creates html/body elements around original HTML fragment, + // so we need to get innerHTML out of the body and not the entire document + return document.querySelector('body').innerHTML; + } + + return html; +}; + export default class Card extends React.PureComponent { static propTypes = { @@ -33,9 +66,16 @@ export default class Card extends React.PureComponent { }; state = { - width: 0, + width: 280, + embedded: false, }; + componentWillReceiveProps (nextProps) { + if (this.props.card !== nextProps.card) { + this.setState({ embedded: false }); + } + } + handlePhotoClick = () => { const { card, onOpenMedia } = this.props; @@ -43,7 +83,7 @@ export default class Card extends React.PureComponent { Immutable.fromJS([ { type: 'image', - url: card.get('url'), + url: card.get('embed_url'), description: card.get('title'), meta: { original: { @@ -57,56 +97,14 @@ export default class Card extends React.PureComponent { ); }; - renderLink () { - const { card, maxDescription } = this.props; - - let image = ''; - let provider = card.get('provider_name'); - - if (card.get('image')) { - image = ( - <div className='status-card__image'> - <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} /> - </div> - ); - } - - if (provider.length < 1) { - provider = decodeIDNA(getHostname(card.get('url'))); - } - - const className = classnames('status-card', { - 'horizontal': card.get('width') > card.get('height'), - }); - - return ( - <a href={card.get('url')} className={className} target='_blank' rel='noopener'> - {image} - - <div className='status-card__content'> - <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong> - <p className='status-card__description'>{(card.get('description') || '').substring(0, maxDescription)}</p> - <span className='status-card__host'>{provider}</span> - </div> - </a> - ); - } - - renderPhoto () { + handleEmbedClick = () => { const { card } = this.props; - return ( - <img - className='status-card-photo' - onClick={this.handlePhotoClick} - role='button' - tabIndex='0' - src={card.get('url')} - alt={card.get('title')} - width={card.get('width')} - height={card.get('height')} - /> - ); + if (card.get('type') === 'photo') { + this.handlePhotoClick(); + } else { + this.setState({ embedded: true }); + } } setRef = c => { @@ -117,7 +115,7 @@ export default class Card extends React.PureComponent { renderVideo () { const { card } = this.props; - const content = { __html: card.get('html') }; + const content = { __html: addAutoPlay(card.get('html')) }; const { width } = this.state; const ratio = card.get('width') / card.get('height'); const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); @@ -125,7 +123,7 @@ export default class Card extends React.PureComponent { return ( <div ref={this.setRef} - className='status-card-video' + className='status-card__image status-card-video' dangerouslySetInnerHTML={content} style={{ height }} /> @@ -133,23 +131,76 @@ export default class Card extends React.PureComponent { } render () { - const { card } = this.props; + const { card, maxDescription } = this.props; + const { width, embedded } = this.state; if (card === null) { return null; } - switch(card.get('type')) { - case 'link': - return this.renderLink(); - case 'photo': - return this.renderPhoto(); - case 'video': - return this.renderVideo(); - case 'rich': - default: - return null; + const provider = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name'); + const horizontal = card.get('width') > card.get('height') && (card.get('width') + 100 >= width) || card.get('type') !== 'link'; + const className = classnames('status-card', { horizontal }); + const interactive = card.get('type') !== 'link'; + const title = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>; + const ratio = card.get('width') / card.get('height'); + const height = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio); + + const description = ( + <div className='status-card__content'> + {title} + {!horizontal && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>} + <span className='status-card__host'>{provider}</span> + </div> + ); + + let embed = ''; + let thumbnail = <div style={{ backgroundImage: `url(${card.get('image')})`, width: horizontal ? width : null, height: horizontal ? height : null }} className='status-card__image-image' />; + + if (interactive) { + if (embedded) { + embed = this.renderVideo(); + } else { + let iconVariant = 'play'; + + if (card.get('type') === 'photo') { + iconVariant = 'search-plus'; + } + + embed = ( + <div className='status-card__image'> + {thumbnail} + + <div className='status-card__actions'> + <div> + <button onClick={this.handleEmbedClick}><i className={`fa fa-${iconVariant}`} /></button> + <a href={card.get('url')} target='_blank' rel='noopener'><i className='fa fa-external-link' /></a> + </div> + </div> + </div> + ); + } + + return ( + <div className={className} ref={this.setRef}> + {embed} + {description} + </div> + ); + } else if (card.get('image')) { + embed = ( + <div className='status-card__image'> + {thumbnail} + </div> + ); } + + return ( + <a href={card.get('url')} className={className} target='_blank' rel='noopener' ref={this.setRef}> + {embed} + {description} + </a> + ); } } diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index ee9eb02c6..2e61e6d8c 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -77,6 +77,7 @@ export default class DetailedStatus extends ImmutablePureComponent { sensitive={status.get('sensitive')} media={status.get('media_attachments')} letterbox={settings.getIn(['media', 'letterbox'])} + fullwidth={settings.getIn(['media', 'fullwidth'])} onOpenMedia={this.props.onOpenMedia} /> ); diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 0697b49ad..5759a575c 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -105,7 +105,7 @@ export default class Status extends ImmutablePureComponent { if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { this._scrolledIntoView = false; this.props.dispatch(fetchStatus(nextProps.params.statusId)); - this.setState({ isExpanded: autoUnfoldCW(nextProps.settings, nextProps.status) }); + this.setState({ isExpanded: autoUnfoldCW(nextProps.settings, nextProps.status), threadExpanded: undefined }); } } diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js index 8fde279c7..71cb7e8c9 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -30,6 +30,12 @@ const componentMap = { 'LIST': ListTimeline, }; +const shouldHideFAB = path => path.match(/^\/statuses\//); + +const messages = defineMessages({ + publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }, +}); + @component => injectIntl(component, { withRef: true }) export default class ColumnsArea extends ImmutablePureComponent { @@ -147,14 +153,14 @@ export default class ColumnsArea extends ImmutablePureComponent { } render () { - const { columns, children, singleColumn } = this.props; + const { columns, children, singleColumn, intl } = this.props; const { shouldAnimate } = this.state; const columnIndex = getIndex(this.context.router.history.location.pathname); this.pendingIndex = null; if (singleColumn) { - const floatingActionButton = this.context.router.history.location.pathname === '/statuses/new' ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button'><i className='fa fa-pencil' /></Link>; + const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><i className='fa fa-pencil' /></Link>; return columnIndex !== -1 ? [ <ReactSwipeableViews key='content' index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index d4fd45d4d..1f3ac18ea 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -129,7 +129,7 @@ export default class MediaModal extends ImmutablePureComponent { startTime={time || 0} onCloseVideo={onClose} detailed - description={image.get('description')} + alt={image.get('description')} key={image.get('url')} /> ); diff --git a/app/javascript/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js index 81643b6c2..a139394ac 100644 --- a/app/javascript/flavours/glitch/features/ui/components/report_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js @@ -106,6 +106,7 @@ export default class ReportModal extends ImmutablePureComponent { onChange={this.handleCommentChange} onKeyDown={this.handleKeyDown} disabled={isSubmitting} + autoFocus /> {domain && ( diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js index b2fee21e1..b44a21a42 100644 --- a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js +++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js @@ -4,10 +4,34 @@ import { NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage, injectIntl } from 'react-intl'; import { debounce } from 'lodash'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; +import { connect } from 'react-redux'; + +const mapStateToProps = state => ({ + unreadNotifications: state.getIn(['notifications', 'unread']), + showBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']), +}); + +@connect(mapStateToProps) +class NotificationsIcon extends React.PureComponent { + static propTypes = { + unreadNotifications: PropTypes.number, + showBadge: PropTypes.bool, + }; + + render() { + const { unreadNotifications, showBadge } = this.props; + return ( + <span className='icon-badge-wrapper'> + <i className='fa fa-fw fa-bell' /> + { showBadge && unreadNotifications > 0 && <div className='icon-badge' />} + </span> + ); + } +} export const links = [ <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, - <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, + <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index e0cb7fc09..69e0ee46e 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -24,7 +24,7 @@ export default class VideoModal extends ImmutablePureComponent { startTime={time} onCloseVideo={onClose} detailed - description={media.get('description')} + alt={media.get('description')} /> </div> </div> diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js index 0a0a4d41a..3f6562bc9 100644 --- a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js +++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js @@ -137,6 +137,7 @@ export default class ZoomableImage extends React.PureComponent { role='presentation' ref={this.setImageRef} alt={alt} + title={alt} src={src} style={{ transform: `scale(${scale})`, diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 1cff94321..ecbac1f8f 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -10,13 +10,14 @@ import { isMobile } from 'flavours/glitch/util/is_mobile'; import { debounce } from 'lodash'; import { uploadCompose, resetCompose } from 'flavours/glitch/actions/compose'; import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; -import { expandNotifications } from 'flavours/glitch/actions/notifications'; +import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { fetchFilters } from 'flavours/glitch/actions/filters'; import { clearHeight } from 'flavours/glitch/actions/height_cache'; import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers'; import UploadArea from './components/upload_area'; import ColumnsAreaContainer from './containers/columns_area_container'; import classNames from 'classnames'; +import Favico from 'favico.js'; import { Drawer, Status, @@ -59,11 +60,14 @@ const messages = defineMessages({ }); const mapStateToProps = state => ({ - hasComposingText: state.getIn(['compose', 'text']) !== '', + hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0, + hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0, layout: state.getIn(['local_settings', 'layout']), isWide: state.getIn(['local_settings', 'stretch']), navbarUnder: state.getIn(['local_settings', 'navbar_under']), dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null, + unreadNotifications: state.getIn(['notifications', 'unread']), + showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']), }); const keyMap = { @@ -110,11 +114,14 @@ export default class UI extends React.Component { navbarUnder: PropTypes.bool, isComposing: PropTypes.bool, hasComposingText: PropTypes.bool, + hasMediaAttachments: PropTypes.bool, match: PropTypes.object.isRequired, location: PropTypes.object.isRequired, history: PropTypes.object.isRequired, intl: PropTypes.object.isRequired, dropdownMenuIsOpen: PropTypes.bool, + unreadNotifications: PropTypes.number, + showFaviconBadge: PropTypes.bool, }; state = { @@ -123,9 +130,9 @@ export default class UI extends React.Component { }; handleBeforeUnload = (e) => { - const { intl, hasComposingText } = this.props; + const { intl, hasComposingText, hasMediaAttachments } = this.props; - if (hasComposingText) { + if (hasComposingText || hasMediaAttachments) { // Setting returnValue to any string causes confirmation dialog. // Many browsers no longer display this text to users, // but we set user-friendly message for other browsers, e.g. Edge. @@ -206,7 +213,27 @@ export default class UI extends React.Component { } } + handleVisibilityChange = () => { + const visibility = !document[this.visibilityHiddenProp]; + this.props.dispatch(notificationsSetVisibility(visibility)); + } + componentWillMount () { + if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support + this.visibilityHiddenProp = 'hidden'; + this.visibilityChange = 'visibilitychange'; + } else if (typeof document.msHidden !== 'undefined') { + this.visibilityHiddenProp = 'msHidden'; + this.visibilityChange = 'msvisibilitychange'; + } else if (typeof document.webkitHidden !== 'undefined') { + this.visibilityHiddenProp = 'webkitHidden'; + this.visibilityChange = 'webkitvisibilitychange'; + } + if (this.visibilityChange !== undefined) { + document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false); + this.handleVisibilityChange(); + } + window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('resize', this.handleResize, { passive: true }); document.addEventListener('dragenter', this.handleDragEnter, false); @@ -219,6 +246,8 @@ export default class UI extends React.Component { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } + this.favicon = new Favico({ animation:"none" }); + this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); setTimeout(() => this.props.dispatch(fetchFilters()), 500); @@ -247,9 +276,19 @@ export default class UI extends React.Component { if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) { this.columnsAreaNode.handleChildrenContentChange(); } + if (this.props.unreadNotifications != prevProps.unreadNotifications || + this.props.showFaviconBadge != prevProps.showFaviconBadge) { + if (this.favicon) { + this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0); + } + } } componentWillUnmount () { + if (this.visibilityChange !== undefined) { + document.removeEventListener(this.visibilityChange, this.handleVisibilityChange); + } + window.removeEventListener('beforeunload', this.handleBeforeUnload); window.removeEventListener('resize', this.handleResize); document.removeEventListener('dragenter', this.handleDragEnter); diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index 7e284a0bc..5cbe01f26 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -101,6 +101,7 @@ export default class Video extends React.PureComponent { fullwidth: PropTypes.bool, detailed: PropTypes.bool, inline: PropTypes.bool, + preventPlayback: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -134,7 +135,10 @@ export default class Video extends React.PureComponent { this.seek = c; } - handleClickRoot = e => e.stopPropagation(); + handleMouseDownRoot = e => { + e.preventDefault(); + e.stopPropagation(); + } handlePlay = () => { this.setState({ paused: false }); @@ -215,6 +219,12 @@ export default class Video extends React.PureComponent { document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); } + componentDidUpdate (prevProps) { + if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) { + this.video.pause(); + } + } + handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }); } @@ -254,11 +264,12 @@ export default class Video extends React.PureComponent { } handleOpenVideo = () => { - const { src, preview, width, height } = this.props; + const { src, preview, width, height, alt } = this.props; const media = fromJS({ type: 'video', url: src, preview_url: preview, + description: alt, width, height, }); @@ -311,7 +322,7 @@ export default class Video extends React.PureComponent { ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} - onClick={this.handleClickRoot} + onMouseDown={this.handleMouseDownRoot} tabIndex={0} > <video |