diff options
author | Starfall <us@starfall.systems> | 2020-10-30 09:55:47 -0500 |
---|---|---|
committer | Starfall <us@starfall.systems> | 2020-10-30 09:55:47 -0500 |
commit | 259470ec37dfc5c3d34ed5456adcd3ab1a622a18 (patch) | |
tree | 0ed8e47864234419df1133ed7cbac481ea8e12dc /app/javascript | |
parent | ab2148ef84c2880465599bd8c80e15378cf0a517 (diff) | |
parent | 5a41704f8926d9594c66028ca30dc1fc0f98da3d (diff) |
Merge branch 'glitch' into main
Diffstat (limited to 'app/javascript')
121 files changed, 2062 insertions, 308 deletions
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js index f2334c254..bbc7cfac7 100644 --- a/app/javascript/core/admin.js +++ b/app/javascript/core/admin.js @@ -1,5 +1,6 @@ // This file will be loaded on admin pages, regardless of theme. +import 'packs/public-path'; import { delegate } from '@rails/ujs'; import ready from '../mastodon/ready'; diff --git a/app/javascript/core/auth.js b/app/javascript/core/auth.js index ca04730a3..d1d14d99e 100644 --- a/app/javascript/core/auth.js +++ b/app/javascript/core/auth.js @@ -1,2 +1,3 @@ +import 'packs/public-path'; import './settings'; import './two_factor_authentication'; diff --git a/app/javascript/core/common.js b/app/javascript/core/common.js index 3b038ca40..1cee2f603 100644 --- a/app/javascript/core/common.js +++ b/app/javascript/core/common.js @@ -1,5 +1,6 @@ // This file will be loaded on all pages, regardless of theme. +import 'packs/public-path'; import 'font-awesome/css/font-awesome.css'; require.context('../images/', true); diff --git a/app/javascript/core/embed.js b/app/javascript/core/embed.js index 6146e6592..9083eb7a3 100644 --- a/app/javascript/core/embed.js +++ b/app/javascript/core/embed.js @@ -1,5 +1,7 @@ // This file will be loaded on embed pages, regardless of theme. +import 'packs/public-path'; + window.addEventListener('message', e => { const data = e.data || {}; diff --git a/app/javascript/core/public.js b/app/javascript/core/public.js index 8c26f1404..b67fb13e5 100644 --- a/app/javascript/core/public.js +++ b/app/javascript/core/public.js @@ -1,5 +1,6 @@ // This file will be loaded on public pages, regardless of theme. +import 'packs/public-path'; import ready from '../mastodon/ready'; const { delegate } = require('@rails/ujs'); diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js index 9403e339b..d5bb9532c 100644 --- a/app/javascript/core/settings.js +++ b/app/javascript/core/settings.js @@ -1,5 +1,6 @@ // This file will be loaded on settings pages, regardless of theme. +import 'packs/public-path'; import escapeTextContentForBrowser from 'escape-html'; const { delegate } = require('@rails/ujs'); import emojify from '../mastodon/features/emoji/emoji'; diff --git a/app/javascript/core/two_factor_authentication.js b/app/javascript/core/two_factor_authentication.js index dde06be8c..f076cdf30 100644 --- a/app/javascript/core/two_factor_authentication.js +++ b/app/javascript/core/two_factor_authentication.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import axios from 'axios'; import * as WebAuthnJSON from '@github/webauthn-json'; import ready from '../mastodon/ready'; diff --git a/app/javascript/flavours/glitch/actions/accounts.js b/app/javascript/flavours/glitch/actions/accounts.js index 428b62f68..912a3d179 100644 --- a/app/javascript/flavours/glitch/actions/accounts.js +++ b/app/javascript/flavours/glitch/actions/accounts.js @@ -274,11 +274,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications) { +export function muteAccount(id, notifications, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index 80bcada6e..c0e7a93af 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) { }; }; -export function submitMarkers() { - return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + return result; }; export const fetchMarkers = () => (dispatch, getState) => { diff --git a/app/javascript/flavours/glitch/actions/mutes.js b/app/javascript/flavours/glitch/actions/mutes.js index 927fc7415..2bacfadb7 100644 --- a/app/javascript/flavours/glitch/actions/mutes.js +++ b/app/javascript/flavours/glitch/actions/mutes.js @@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; export function fetchMutes() { return (dispatch, getState) => { @@ -104,3 +105,12 @@ export function toggleHideNotifications() { dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; } + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 7f311153b..eb7087027 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -16,6 +16,7 @@ import { getFiltersRegex } from 'flavours/glitch/selectors'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/util/initial_state'; import compareId from 'flavours/glitch/util/compare_id'; import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; +import { requestNotificationPermission } from 'flavours/glitch/util/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -46,8 +47,12 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; + export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); @@ -327,3 +332,42 @@ export function markNotificationsAsRead() { type: NOTIFICATIONS_MARK_AS_READ, }; }; + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js new file mode 100644 index 000000000..4085cb59e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js @@ -0,0 +1,38 @@ +// @ts-check + +export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; +export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; + +/** + * @typedef MediaProps + * @property {string} src + * @property {boolean} muted + * @property {number} volume + * @property {number} currentTime + * @property {string} poster + * @property {string} backgroundColor + * @property {string} foregroundColor + * @property {string} accentColor + */ + +/** + * @param {string} statusId + * @param {string} accountId + * @param {string} playerType + * @param {MediaProps} props + * @return {object} + */ +export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ + type: PICTURE_IN_PICTURE_DEPLOY, + statusId, + accountId, + playerType, + props, +}); + +/* + * @return {object} + */ +export const removePictureInPicture = () => ({ + type: PICTURE_IN_PICTURE_REMOVE, +}); diff --git a/app/javascript/flavours/glitch/components/account.js b/app/javascript/flavours/glitch/components/account.js index f3e58dfe3..23399c630 100644 --- a/app/javascript/flavours/glitch/components/account.js +++ b/app/javascript/flavours/glitch/components/account.js @@ -8,6 +8,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from 'flavours/glitch/util/initial_state'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -116,6 +117,11 @@ class Account extends ImmutablePureComponent { } } + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; + } + return small ? ( <Permalink className='account small' @@ -138,6 +144,7 @@ class Account extends ImmutablePureComponent { <div className='account__wrapper'> <Permalink key={account.get('id')} className='account__display-name' href={account.get('url')} to={`/accounts/${account.get('id')}`}> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + {mute_expires_at} <DisplayName account={account} /> </Permalink> {buttons ? diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js index e3235e368..3cc5173dd 100644 --- a/app/javascript/flavours/glitch/components/animated_number.js +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; import { reduceMotion } from 'flavours/glitch/util/initial_state'; +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + export default class AnimatedNumber extends React.PureComponent { static propTypes = { value: PropTypes.number.isRequired, + obfuscate: PropTypes.bool, }; state = { @@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent { } render () { - const { value } = this.props; + const { value, obfuscate } = this.props; const { direction } = this.state; if (reduceMotion) { - return <FormattedNumber value={value} />; + return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />; } const styles = [{ @@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent { {items => ( <span className='animated-number'> {items.map(({ key, data, style }) => ( - <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span> + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span> ))} </span> )} diff --git a/app/javascript/flavours/glitch/components/autosuggest_emoji.js b/app/javascript/flavours/glitch/components/autosuggest_emoji.js index c8609e48f..d04c1eb68 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_emoji.js +++ b/app/javascript/flavours/glitch/components/autosuggest_emoji.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light'; -const assetHost = process.env.CDN_HOST || ''; +import { assetHost } from 'flavours/glitch/util/config'; export default class AutosuggestEmoji extends React.PureComponent { diff --git a/app/javascript/flavours/glitch/components/column_header.js b/app/javascript/flavours/glitch/components/column_header.js index 01bd4a246..ccd0714f1 100644 --- a/app/javascript/flavours/glitch/components/column_header.js +++ b/app/javascript/flavours/glitch/components/column_header.js @@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { onMove: PropTypes.func, onClick: PropTypes.func, appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, }; state = { @@ -88,7 +89,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -150,7 +151,20 @@ class ColumnHeader extends React.PureComponent { } if (children || (multiColumn && this.props.onPin)) { - collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; + collapseButton = ( + <button + className={collapsibleButtonClassName} + title={formatMessage(collapsed ? messages.show : messages.hide)} + aria-label={formatMessage(collapsed ? messages.show : messages.hide)} + aria-pressed={collapsed ? 'false' : 'true'} + onClick={this.handleToggleClick} + > + <i className='icon-with-badge'> + <Icon id='sliders' /> + {collapseIssues && <i className='icon-with-badge__issue-badge' />} + </i> + </button> + ); } const hasTitle = icon && title; diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index e134d0a39..58d3568dd 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; export default class IconButton extends React.PureComponent { @@ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent { overlay: PropTypes.bool, tabIndex: PropTypes.string, label: PropTypes.string, + counter: PropTypes.number, + obfuscateCount: PropTypes.bool, }; static defaultProps = { @@ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent { pressed, tabIndex, title, + counter, + obfuscateCount, } = this.props; const { @@ -118,8 +123,13 @@ export default class IconButton extends React.PureComponent { activate, deactivate, overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', }); + if (typeof counter !== 'undefined') { + style.width = 'auto'; + } + return ( <button aria-label={title} @@ -135,7 +145,7 @@ export default class IconButton extends React.PureComponent { tabIndex={tabIndex} disabled={disabled} > - <Icon id={icon} fixedWidth aria-hidden='true' /> + <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>} {this.props.label} </button> ); diff --git a/app/javascript/flavours/glitch/components/icon_with_badge.js b/app/javascript/flavours/glitch/components/icon_with_badge.js index 219efc28c..a42ba4589 100644 --- a/app/javascript/flavours/glitch/components/icon_with_badge.js +++ b/app/javascript/flavours/glitch/components/icon_with_badge.js @@ -4,16 +4,18 @@ import Icon from 'flavours/glitch/components/icon'; const formatNumber = num => num > 40 ? '40+' : num; -const IconWithBadge = ({ id, count, className }) => ( +const IconWithBadge = ({ id, count, issueBadge, className }) => ( <i className='icon-with-badge'> <Icon id={id} fixedWidth className={className} /> {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} + {issueBadge && <i className='icon-with-badge__issue-badge' />} </i> ); IconWithBadge.propTypes = { id: PropTypes.string.isRequired, count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, className: PropTypes.string, }; diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js new file mode 100644 index 000000000..01dce0a38 --- /dev/null +++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; +import { FormattedMessage } from 'react-intl'; + +export default @connect() +class PictureInPicturePlaceholder extends React.PureComponent { + + static propTypes = { + width: PropTypes.number, + dispatch: PropTypes.func.isRequired, + }; + + state = { + width: this.props.width, + height: this.props.width && (this.props.width / (16/9)), + }; + + handleClick = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + setRef = c => { + this.node = c; + + if (this.node) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.node.offsetWidth; + const height = width / (16/9); + + this.setState({ width, height }); + } + + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + render () { + const { height } = this.state; + + return ( + <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}> + <Icon id='window-restore' /> + <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index fc7940e5a..1b7dce4c4 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -17,6 +17,7 @@ import classNames from 'classnames'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; import PollContainer from 'flavours/glitch/containers/poll_container'; import { displayMedia } from 'flavours/glitch/util/initial_state'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -97,6 +98,8 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, onClick: PropTypes.func, scrollKey: PropTypes.string, + deployPictureInPicture: PropTypes.func, + usingPiP: PropTypes.bool, }; state = { @@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent { 'hidden', 'expanded', 'unread', + 'usingPiP', ] updateOnStates = [ @@ -394,6 +398,12 @@ class Status extends ImmutablePureComponent { } } + handleDeployPictureInPicture = (type, mediaProps) => { + const { deployPictureInPicture, status } = this.props; + + deployPictureInPicture(status, type, mediaProps); + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this.props.status, this.context.router.history); @@ -496,6 +506,7 @@ class Status extends ImmutablePureComponent { hidden, unread, featured, + usingPiP, ...other } = this.props; const { isExpanded, isCollapsed, forceFilter } = this.state; @@ -576,6 +587,9 @@ class Status extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; mediaIcon = 'tasks'; + } else if (usingPiP) { + media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; + mediaIcon = 'video-camera'; } else if (attachments.size > 0) { if (muted || attachments.some(item => item.get('type') === 'unknown')) { media = ( @@ -601,6 +615,7 @@ class Status extends ImmutablePureComponent { width={this.props.cachedMediaWidth} height={110} cacheWidth={this.props.cacheMediaWidth} + deployPictureInPicture={this.handleDeployPictureInPicture} /> )} </Bundle> @@ -624,6 +639,7 @@ class Status extends ImmutablePureComponent { onOpenVideo={this.handleOpenVideo} width={this.props.cachedMediaWidth} cacheWidth={this.props.cacheMediaWidth} + deployPictureInPicture={this.handleDeployPictureInPicture} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} />)} diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index cfb03c21b..2ccb02c62 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -40,16 +40,6 @@ const messages = defineMessages({ hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, }); -const obfuscatedCount = count => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - export default @injectIntl class StatusActionBar extends ImmutablePureComponent { @@ -284,10 +274,14 @@ class StatusActionBar extends ImmutablePureComponent { ); if (showReplyCount) { replyButton = ( - <div className='status__action-bar__counter'> - {replyButton} - <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span> - </div> + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + counter={status.get('replies_count')} + obfuscateCount + /> ); } diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js index 8101be87e..762280bec 100644 --- a/app/javascript/flavours/glitch/containers/mastodon.js +++ b/app/javascript/flavours/glitch/containers/mastodon.js @@ -32,13 +32,6 @@ export default class Mastodon extends React.PureComponent { componentDidMount() { this.disconnect = store.dispatch(connectUserStream()); - - // Desktop notifications - // Ask after 1 minute - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 60 * 1000); - } - store.dispatch(showOnboardingOnce()); } diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 2cbe3d094..ac423c58d 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -22,6 +22,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { openModal } from 'flavours/glitch/actions/modal'; +import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; @@ -69,6 +70,7 @@ const makeMapStateToProps = () => { account : account || props.account, settings : state.get('local_settings'), prepend : prepend || props.prepend, + usingPiP : state.get('picture_in_picture').statusId === props.id, }; }; @@ -245,6 +247,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + deployPictureInPicture (status, type, mediaProps) { + dispatch((_, getState) => { + if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) { + dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); + } + }); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js index 7a2fb7fb6..6d09ac8d2 100644 --- a/app/javascript/flavours/glitch/features/audio/index.js +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -37,7 +37,11 @@ class Audio extends React.PureComponent { backgroundColor: PropTypes.string, foregroundColor: PropTypes.string, accentColor: PropTypes.string, + currentTime: PropTypes.number, autoPlay: PropTypes.bool, + volume: PropTypes.number, + muted: PropTypes.bool, + deployPictureInPicture: PropTypes.func, }; state = { @@ -64,6 +68,19 @@ class Audio extends React.PureComponent { } } + _pack() { + return { + src: this.props.src, + volume: this.audio.volume, + muted: this.audio.muted, + currentTime: this.audio.currentTime, + poster: this.props.poster, + backgroundColor: this.props.backgroundColor, + foregroundColor: this.props.foregroundColor, + accentColor: this.props.accentColor, + }; + } + _setDimensions () { const width = this.player.offsetWidth; const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); @@ -100,6 +117,7 @@ class Audio extends React.PureComponent { } componentDidMount () { + window.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize, { passive: true }); } @@ -115,7 +133,12 @@ class Audio extends React.PureComponent { } componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('resize', this.handleResize); + + if (!this.state.paused && this.audio && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } } togglePlay = () => { @@ -243,6 +266,25 @@ class Audio extends React.PureComponent { } }, 15); + handleScroll = throttle(() => { + if (!this.canvas || !this.audio) { + return; + } + + const { top, height } = this.canvas.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.audio.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } + + this.setState({ paused: true }); + } + }, 150, { trailing: true }); + handleMouseEnter = () => { this.setState({ hovered: true }); } @@ -252,10 +294,22 @@ class Audio extends React.PureComponent { } handleLoadedData = () => { - const { autoPlay } = this.props; + const { autoPlay, currentTime, volume, muted } = this.props; + + if (currentTime) { + this.audio.currentTime = currentTime; + } + + if (volume !== undefined) { + this.audio.volume = volume; + } + + if (muted !== undefined) { + this.audio.muted = muted; + } if (autoPlay) { - this.audio.play(); + this.togglePlay(); } } @@ -341,7 +395,7 @@ class Audio extends React.PureComponent { render () { const { src, intl, alt, editable, autoPlay } = this.props; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; - const progress = (currentTime / duration) * 100; + const progress = Math.min((currentTime / duration) * 100, 100); return ( <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js index d0d9714a8..89219d739 100644 --- a/app/javascript/flavours/glitch/features/emoji_picker/index.js +++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js @@ -13,6 +13,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji'; import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; +import { assetHost } from 'flavours/glitch/util/config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -105,7 +106,6 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ }, }); -const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js index acaa78fe3..cd81d07de 100644 --- a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js +++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js @@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker'; import AnimatedNumber from 'flavours/glitch/components/animated_number'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import { assetHost } from 'flavours/glitch/util/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent { } -const assetHost = process.env.CDN_HOST || ''; - class Emoji extends React.PureComponent { static propTypes = { 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 0b3428027..3af6cbdf6 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -28,6 +28,8 @@ const messages = defineMessages({ rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' }, rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' }, rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage: 'Rewrite with username' }, + pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' }, + pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' }, }); export default @injectIntl @@ -111,6 +113,14 @@ class LocalSettingsPage extends React.PureComponent { <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' /> <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span> </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['notifications', 'show_unread']} + id='mastodon-settings--notifications-show_unread' + onChange={onChange} + > + <FormattedMessage id='settings.notifications.show_unread' defaultMessage='Show unread marker' /> + </LocalSettingsPageItem> </section> <section> <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2> @@ -384,7 +394,7 @@ class LocalSettingsPage extends React.PureComponent { </section> </div> ), - ({ onChange, settings }) => ( + ({ intl, onChange, settings }) => ( <div className='glitch local-settings__page media'> <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1> <LocalSettingsPageItem @@ -420,6 +430,27 @@ class LocalSettingsPage extends React.PureComponent { > <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' /> </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'pop_in_player']} + id='mastodon-settings--pop-in-player' + onChange={onChange} + > + <FormattedMessage id='settings.pop_in_player' defaultMessage='Enable pop-in player' /> + </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['media', 'pop_in_position']} + id='mastodon-settings--pop-in-position' + options={[ + { value: 'left', message: intl.formatMessage(messages.pop_in_left) }, + { value: 'right', message: intl.formatMessage(messages.pop_in_right) }, + ]} + onChange={onChange} + dependsOn={[['media', 'pop_in_player']]} + > + <FormattedMessage id='settings.pop_in_position' defaultMessage='Pop-in player position:' /> + </LocalSettingsPageItem> </div> ), ]; diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js index e4d5d0eda..9748219dd 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js +++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js @@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent { pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, + onRequestNotificationPermission: PropTypes.func, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, }; onPushChange = (path, checked) => { @@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent { } render () { - const { settings, pushSettings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; @@ -33,6 +37,12 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + <div className='column-settings__row'> <ClearColumnButton onClick={onClear} /> </div> @@ -41,6 +51,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-filter-bar' className='column-settings__section'> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> </span> + <div className='column-settings__row'> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> @@ -51,7 +62,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> @@ -62,7 +73,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> @@ -73,7 +84,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> @@ -84,7 +95,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> @@ -95,7 +106,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> @@ -106,12 +117,23 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> + + <div className='column-settings__row'> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js new file mode 100644 index 000000000..8e77f5a03 --- /dev/null +++ b/app/javascript/flavours/glitch/features/notifications/components/notifications_permission_banner.js @@ -0,0 +1,30 @@ +import React from 'react'; +import Icon from 'flavours/glitch/components/icon'; +import Button from 'flavours/glitch/components/button'; +import { requestBrowserPermission } from 'flavours/glitch/actions/notifications'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default @connect(() => {}) +class NotificationsPermissionBanner extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + handleClick = () => { + this.props.dispatch(requestBrowserPermission()); + } + + render () { + return ( + <div className='notifications-permission-banner'> + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js index 0264b6815..e472f7c4f 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js +++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js @@ -13,6 +13,7 @@ export default class SettingToggle extends React.PureComponent { meta: PropTypes.node, onChange: PropTypes.func.isRequired, defaultValue: PropTypes.bool, + disabled: PropTypes.bool, } onChange = ({ target }) => { @@ -20,12 +21,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, meta, defaultValue } = this.props; + const { prefix, settings, settingPath, label, meta, defaultValue, disabled } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> {meta && <span className='setting-meta__label'>{meta}</span>} </div> diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js index 4b863712a..c2564f44e 100644 --- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js +++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js @@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from 'flavours/glitch/actions/settings'; import { setFilter } from 'flavours/glitch/actions/notifications'; -import { clearNotifications } from 'flavours/glitch/actions/notifications'; +import { clearNotifications, requestBrowserPermission } from 'flavours/glitch/actions/notifications'; import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications'; import { openModal } from 'flavours/glitch/actions/modal'; +import { showAlert } from 'flavours/glitch/actions/alerts'; const messages = defineMessages({ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, }); const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { - dispatch(changePushNotifications(path.slice(1), checked)); + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } } else if (path[0] === 'quickFilter') { dispatch(changeSetting(['notifications', ...path], checked)); dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } } else { dispatch(changeSetting(['notifications', ...path], checked)); } @@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + }); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 475968caa..97434b586 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -27,6 +27,7 @@ import ScrollableList from 'flavours/glitch/components/scrollable_list'; import LoadGap from 'flavours/glitch/components/load_gap'; import Icon from 'flavours/glitch/components/icon'; import compareId from 'flavours/glitch/util/compare_id'; +import NotificationsPermissionBanner from './components/notifications_permission_banner'; import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; @@ -60,8 +61,9 @@ const mapStateToProps = state => ({ hasMore: state.getIn(['notifications', 'hasMore']), numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), - lastReadId: state.getIn(['notifications', 'readMarkerId']), - canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + lastReadId: state.getIn(['local_settings', 'notifications', 'show_unread']) ? state.getIn(['notifications', 'readMarkerId']) : '0', + canMarkAsRead: state.getIn(['local_settings', 'notifications', 'show_unread']) && state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default', }); /* glitch */ @@ -71,7 +73,7 @@ const mapDispatchToProps = dispatch => ({ }, onMarkAsRead() { dispatch(markNotificationsAsRead()); - dispatch(submitMarkers()); + dispatch(submitMarkers({ immediate: true })); }, onMount() { dispatch(mountNotifications()); @@ -105,6 +107,7 @@ class Notifications extends React.PureComponent { onUnmount: PropTypes.func, lastReadId: PropTypes.string, canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, }; static defaultProps = { @@ -211,7 +214,7 @@ class Notifications extends React.PureComponent { } render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; const { notifCleaning, notifCleaningActive } = this.props; const { animatingNCD } = this.state; const pinned = !!columnId; @@ -257,6 +260,8 @@ class Notifications extends React.PureComponent { showLoading={isLoading && notifications.size === 0} hasMore={hasMore} numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} onLoadPending={this.handleLoadPending} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js new file mode 100644 index 000000000..2ddba140e --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js @@ -0,0 +1,162 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import classNames from 'classnames'; +import { me, boostModal } from 'flavours/glitch/util/initial_state'; +import { defineMessages, injectIntl } from 'react-intl'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { statusId }) => ({ + status: getStatus(state, { id: statusId }), + askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, + showReplyCount: state.getIn(['local_settings', 'show_reply_count']), + }); + + return mapStateToProps; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +class Footer extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + statusId: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + askReplyConfirmation: PropTypes.bool, + showReplyCount: PropTypes.bool, + }; + + _performReply = () => { + const { dispatch, status } = this.props; + dispatch(replyCompose(status, this.context.router.history)); + }; + + handleReplyClick = () => { + const { dispatch, askReplyConfirmation, intl } = this.props; + + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: this._performReply, + })); + } else { + this._performReply(); + } + }; + + handleFavouriteClick = () => { + const { dispatch, status } = this.props; + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + _performReblog = () => { + const { dispatch, status } = this.props; + dispatch(reblog(status)); + } + + handleReblogClick = e => { + const { dispatch, status } = this.props; + + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else if ((e && e.shiftKey) || !boostModal) { + this._performReblog(); + } else { + dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); + } + }; + + render () { + const { status, intl, showReplyCount } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let replyIcon, replyTitle; + + 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); + } + + let reblogTitle = ''; + + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_reblog); + } + + let replyButton = null; + if (showReplyCount) { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} + onClick={this.handleReplyClick} + counter={status.get('replies_count')} + obfuscateCount + /> + ); + } else { + replyButton = ( + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} + onClick={this.handleReplyClick} + /> + ); + } + + return ( + <div className='picture-in-picture__footer'> + {replyButton} + <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js new file mode 100644 index 000000000..24adcde25 --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { Link } from 'react-router-dom'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; + +const mapStateToProps = (state, { accountId }) => ({ + account: state.getIn(['accounts', accountId]), +}); + +export default @connect(mapStateToProps) +class Header extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + }; + + render () { + const { account, statusId, onClose } = this.props; + + return ( + <div className='picture-in-picture__header'> + <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'> + <Avatar account={account} size={36} /> + <DisplayName account={account} /> + </Link> + + <IconButton icon='times' onClick={onClose} title='Close' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.js b/app/javascript/flavours/glitch/features/picture_in_picture/index.js new file mode 100644 index 000000000..3e6a20faa --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.js @@ -0,0 +1,88 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import Header from './components/header'; +import Footer from './components/footer'; +import classNames from 'classnames'; + +const mapStateToProps = state => ({ + ...state.get('picture_in_picture'), + left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left', +}); + +export default @connect(mapStateToProps) +class PictureInPicture extends React.Component { + + static propTypes = { + statusId: PropTypes.string, + accountId: PropTypes.string, + type: PropTypes.string, + src: PropTypes.string, + muted: PropTypes.bool, + volume: PropTypes.number, + currentTime: PropTypes.number, + poster: PropTypes.string, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, + dispatch: PropTypes.func.isRequired, + left: PropTypes.bool, + }; + + handleClose = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + render () { + const { type, src, currentTime, accountId, statusId, left } = this.props; + + if (!currentTime) { + return null; + } + + let player; + + if (type === 'video') { + player = ( + <Video + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + autoPlay + inline + alwaysVisible + /> + ); + } else if (type === 'audio') { + player = ( + <Audio + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + poster={this.props.poster} + backgroundColor={this.props.backgroundColor} + foregroundColor={this.props.foregroundColor} + accentColor={this.props.accentColor} + autoPlay + /> + ); + } + + return ( + <div className={classNames('picture-in-picture', { left })}> + <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> + + {player} + + <Footer statusId={statusId} /> + </div> + ); + } + +} 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 e4aecbf94..04d350bcb 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -18,6 +18,7 @@ import classNames from 'classnames'; import PollContainer from 'flavours/glitch/containers/poll_container'; import Icon from 'flavours/glitch/components/icon'; import AnimatedNumber from 'flavours/glitch/components/animated_number'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; export default class DetailedStatus extends ImmutablePureComponent { @@ -37,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent { domain: PropTypes.string.isRequired, compact: PropTypes.bool, showMedia: PropTypes.bool, + usingPiP: PropTypes.bool, onToggleMediaVisibility: PropTypes.func, }; @@ -109,7 +111,7 @@ export default class DetailedStatus extends ImmutablePureComponent { render () { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; - const { expanded, onToggleHidden, settings } = this.props; + const { expanded, onToggleHidden, settings, usingPiP } = this.props; const outerStyle = { boxSizing: 'border-box' }; const { compact } = this.props; @@ -131,6 +133,9 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; mediaIcon = 'tasks'; + } else if (usingPiP) { + media = <PictureInPicturePlaceholder />; + mediaIcon = 'video-camera'; } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 3e2e95f35..b330adf3f 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -132,6 +132,7 @@ const makeMapStateToProps = () => { settings: state.get('local_settings'), askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), + usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, }; }; @@ -157,6 +158,7 @@ class Status extends ImmutablePureComponent { askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, + usingPiP: PropTypes.bool, }; state = { @@ -514,7 +516,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; const { setExpansion } = this; - const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; + const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; const { fullscreen, isExpanded } = this.state; if (status === null) { @@ -578,6 +580,7 @@ class Status extends ImmutablePureComponent { domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} + usingPiP={usingPiP} /> <ActionBar diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js index b790b29a0..5de3e26d5 100644 --- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js @@ -20,6 +20,7 @@ import GIFV from 'flavours/glitch/components/gifv'; import { me } from 'flavours/glitch/util/initial_state'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'flavours/glitch/util/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); -const assetHost = process.env.CDN_HOST || ''; - class ImageLoader extends React.PureComponent { static propTypes = { 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 23e8dac7e..aa6554107 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -140,7 +140,7 @@ class MediaModal extends ImmutablePureComponent { src={image.get('url')} width={image.get('width')} height={image.get('height')} - startTime={time || 0} + currentTime={time || 0} onCloseVideo={onClose} detailed alt={image.get('description')} diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js index 2aab82751..7d25db316 100644 --- a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js @@ -1,25 +1,32 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Button from 'flavours/glitch/components/button'; import { closeModal } from 'flavours/glitch/actions/modal'; import { muteAccount } from 'flavours/glitch/actions/accounts'; -import { toggleHideNotifications } from 'flavours/glitch/actions/mutes'; +import { toggleHideNotifications, changeMuteDuration } from 'flavours/glitch/actions/mutes'; +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); }, onClose() { @@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => { onToggleNotifications() { dispatch(toggleHideNotifications()); }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, }; }; @@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent { onConfirm: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, }; componentDidMount() { @@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); } handleCancel = () => { @@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + render () { - const { account, notifications } = this.props; + const { account, notifications, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent { <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> </label> </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> </div> <div className='mute-modal__action-bar'> 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 afeff90a4..c8d2a81b0 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -42,9 +42,9 @@ export default class VideoModal extends ImmutablePureComponent { preview={media.get('preview_url')} blurhash={media.get('blurhash')} src={media.get('url')} - startTime={options.startTime} + currentTime={options.startTime} autoPlay={options.autoPlay} - defaultVolume={options.defaultVolume} + volume={options.defaultVolume} onCloseVideo={onClose} detailed alt={media.get('description')} diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index a399fc2b3..61a34fd2b 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -19,6 +19,7 @@ import PermaLink from 'flavours/glitch/components/permalink'; import ColumnsAreaContainer from './containers/columns_area_container'; import classNames from 'classnames'; import Favico from 'favico.js'; +import PictureInPicture from 'flavours/glitch/features/picture_in_picture'; import { Compose, Status, @@ -359,7 +360,7 @@ class UI extends React.Component { const visibility = !document[this.visibilityHiddenProp]; this.props.dispatch(notificationsSetVisibility(visibility)); if (visibility) { - this.props.dispatch(submitMarkers()); + this.props.dispatch(submitMarkers({ immediate: true })); } } @@ -385,7 +386,7 @@ class UI extends React.Component { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support @@ -614,6 +615,7 @@ class UI extends React.Component { {children} </SwitchingColumnsArea> + <PictureInPicture /> <NotificationsContainer /> <LoadingBarContainer className='loading-bar' /> <ModalContainer /> diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index cc60a0d2e..95bee1331 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -103,7 +103,7 @@ class Video extends React.PureComponent { width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, - startTime: PropTypes.number, + currentTime: PropTypes.number, onOpenVideo: PropTypes.func, onCloseVideo: PropTypes.func, letterbox: PropTypes.bool, @@ -111,15 +111,18 @@ class Video extends React.PureComponent { detailed: PropTypes.bool, inline: PropTypes.bool, editable: PropTypes.bool, + alwaysVisible: PropTypes.bool, cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, visible: PropTypes.bool, onToggleVisibility: PropTypes.func, + deployPictureInPicture: PropTypes.func, preventPlayback: PropTypes.bool, blurhash: PropTypes.string, link: PropTypes.node, autoPlay: PropTypes.bool, - defaultVolume: PropTypes.number, + volume: PropTypes.number, + muted: PropTypes.bool, }; state = { @@ -298,16 +301,27 @@ class Video extends React.PureComponent { document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + window.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize, { passive: true }); } componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('resize', this.handleResize); document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (!this.state.paused && this.video && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } } componentDidUpdate (prevProps) { @@ -330,6 +344,30 @@ class Video extends React.PureComponent { trailing: true, }); + handleScroll = throttle(() => { + if (!this.video) { + return; + } + + const { top, height } = this.video.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.video.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } + + this.setState({ paused: true }); + } + }, 150, { trailing: true }) + handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }); } @@ -360,15 +398,21 @@ class Video extends React.PureComponent { } handleLoadedData = () => { - if (this.props.startTime) { - this.video.currentTime = this.props.startTime; + const { currentTime, volume, muted, autoPlay } = this.props; + + if (currentTime) { + this.video.currentTime = currentTime; + } + + if (volume !== undefined) { + this.video.volume = volume; } - if (this.props.defaultVolume !== undefined) { - this.video.volume = this.props.defaultVolume; + if (muted !== undefined) { + this.video.muted = muted; } - if (this.props.autoPlay) { + if (autoPlay) { this.video.play(); } } @@ -413,9 +457,9 @@ class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props; + const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; - const progress = (currentTime / duration) * 100; + const progress = Math.min((currentTime / duration) * 100, 100); const playerStyle = {}; const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth }); @@ -440,7 +484,7 @@ class Video extends React.PureComponent { let preload; - if (startTime || fullscreen || dragging) { + if (this.props.currentTime || fullscreen || dragging) { preload = 'auto'; } else if (detailed) { preload = 'metadata'; @@ -532,7 +576,7 @@ class Video extends React.PureComponent { </div> <div className='video-player__buttons right'> - {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> diff --git a/app/javascript/flavours/glitch/locales/es.js b/app/javascript/flavours/glitch/locales/es.js index 456df3c47..086873881 100644 --- a/app/javascript/flavours/glitch/locales/es.js +++ b/app/javascript/flavours/glitch/locales/es.js @@ -1,7 +1,119 @@ import inherited from 'mastodon/locales/es.json'; const messages = { - // No translations available. + 'advanced_options.icon_title': 'Opciones avanzadas', + 'advanced_options.local-only.long': 'No publicar a otras instancias', + 'advanced_options.local-only.short': 'Local', + 'advanced_options.local-only.tooltip': 'Este toot es local', + 'advanced_options.threaded_mode.long': 'Al publicar abre automáticamente una respuesta', + 'advanced_options.threaded_mode.short': 'Modo hilo', + 'advanced_options.threaded_mode.tooltip': 'Modo hilo habilitado', + 'compose.attach.doodle': 'Dibujar algo', + 'compose.attach.upload': 'Subir un archivo', + 'compose.attach': 'Adjuntar...', + 'favourite_modal.combo': 'Puedes presionar {combo} para omitir esto la próxima vez', + 'getting_started.onboarding': 'Paseo inicial', + 'getting_started.open_source_notice': 'Glitchsoc es software libre y gratuito bifurcado de {Mastodon}. Puedes contribuir o reportar errores en GitHub en {github}.', + 'home.column_settings.show_direct': 'Mostrar mensajes directos', + 'layout.auto': 'Automático', + 'layout.current_is': 'Tu diseño actual es:', + 'layout.desktop': 'Escritorio', + 'layout.hint.auto': 'Seleccionar un diseño automáticamente basado en "Habilitar interface web avanzada" y tamaño de pantalla', + 'layout.hint.desktop': 'Utiliza el diseño multi-columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla', + 'layout.hint.single': 'Utiliza el diseño de una columna sin importar "Habilitar interface web avanzada" o tamaño de pantalla', + 'layout.mobile': 'Móvil', + 'media_gallery.sensitive': 'Sensible', + 'navigation_bar.app_settings': 'Ajustes de aplicación', + 'notification_purge.btn_all': 'Seleccionar\ntodo', + 'notification_purge.btn_apply': 'Borrar\nselección', + 'notification_purge.btn_invert': 'Invertir\nselección', + 'notification_purge.btn_none': 'Seleccionar\nnada', + 'notification.markForDeletion': 'Marcar para borrar', + 'notifications.clear': 'Limpiar notificaciones', + 'notifications.marked_clear_confirmation': '¿Deseas borrar permanentemente todas las notificaciones seleccionadas?', + 'notifications.marked_clear': 'Limpiar notificaciones seleccionadas', + 'onboarding.page_one.federation': '{domain} es una "instancia" de Mastodon. Mastodon es una red de servidores independientes que se unen para crear una red social más grande. A estos servidores los llamamos instancias.', + 'onboarding.page_one.welcome': '¡Bienvenidx a {domain}!', + 'onboarding.page_six.github': '{domain} usa Glitchsoc. Glitchsoc es una bifurcación {fork} amigable de {Mastodon}, y es compatible con cualquier instancia o aplicación de Mastodon. Glitchsoc es completamente gratuito y de código abierto. Puedes reportar errores, solicitar funciones o contribuir al código en {github}.', + 'settings.always_show_spoilers_field': 'Siempre mostrar el campo de advertencia de contenido', + 'settings.auto_collapse_all': 'Todo', + 'settings.auto_collapse_lengthy': 'Toots largos', + 'settings.auto_collapse_media': 'Toots con medios', + 'settings.auto_collapse_notifications': 'Notificaciones', + 'settings.auto_collapse_reblogs': 'Retoots', + 'settings.auto_collapse_replies': 'Respuestas', + 'settings.auto_collapse': 'Colapsar automáticamente', + 'settings.close': 'Cerrar', + 'settings.collapsed_statuses': 'Toots colapsados', + 'settings.compose_box_opts': 'Cuadro de redacción', + 'settings.confirm_before_clearing_draft': 'Mostrar diálogo de confirmación antes de sobreescribir un mensaje estabas escribiendo', + 'settings.confirm_boost_missing_media_description': 'Mostrar diálogo de confirmación antes de retootear publicaciones con medios sin descripción', + 'settings.confirm_missing_media_description': 'Mostrar diálogo de confirmación antes de publicar toots con medios sin descripción', + 'settings.content_warnings_filter': 'No descolapsar estas advertencias de contenido:', + 'settings.content_warnings.regexp': 'Regexp (expresión regular)', + 'settings.content_warnings': 'Advertencias de contenido', + 'settings.enable_collapsed': 'Habilitar toots colapsados', + 'settings.enable_content_warnings_auto_unfold': 'Descolapsar automáticamente advertencias de contenido', + 'settings.filtering_behavior.cw': 'Mostrar el toot y agregar las palabras filtradas a la advertencia de contenido', + 'settings.filtering_behavior.drop': 'Ocultar toots filtrados por completo', + 'settings.filtering_behavior.hide': 'Mostrar "Filtrado" y agregar un botón para saber por qué', + 'settings.filtering_behavior.upstream': 'Mostrar "Filtrado"', + 'settings.filtering_behavior': 'Configuración de filtros', + 'settings.filters': 'Filtros', + 'settings.general': 'General', + 'settings.hicolor_privacy_icons': 'Íconos de privacidad más visibles', + 'settings.image_backgrounds_media': 'Vista previa de medios de toots colapsados', + 'settings.image_backgrounds_users': 'Darle fondo de imagen a toots colapsados', + 'settings.image_backgrounds': 'Fondos de imágenes', + 'settings.inline_preview_cards': 'Vista previa para enlaces externos', + 'settings.layout_opts': 'Opciones de diseño', + 'settings.layout': 'Diseño', + 'settings.media_fullwidth': 'Ancho completo al mostrar medios ', + 'settings.media_letterbox_hint': 'Escalar medios para que llenen el espacio del contenedor sin cambiar sus proporciones sin recortarlos', + 'settings.media_letterbox': 'Mantener proporciones al mostrar medios', + 'settings.media_reveal_behind_cw': 'Siempre mostrar medios sensibles dentro de las advertencias de contenido', + 'settings.media': 'Medios', + 'settings.navbar_under': 'Barra de navegación en la parte inferior (solo móvil)', + 'settings.notifications_opts': 'Opciones de notificaciones', + 'settings.notifications.favicon_badge.hint': 'Muestra un marcador de notificaciones sin leer en el favicon', + 'settings.notifications.favicon_badge': 'Marcador de notificaciones en el favicon', + 'settings.notifications.tab_badge.hint': 'Muestra un marcador de notificaciones sin leer en el ícono de notificaciones cuando dicha columna no está abierta', + 'settings.notifications.tab_badge': 'Marcador de notificaciones no leídas', + 'settings.preferences': 'Preferencias de usuarix', + 'settings.prepend_cw_re': 'Anteponer "re: " a las advertencias de contenido al responder', + 'settings.preselect_on_reply_hint': 'Al responder a conversaciones con múltiples participantes, preselecciona los nombres de usuarix subsecuentes del/la primerx', + 'settings.preselect_on_reply': 'Preseleccionar nombres de usuarix al responder', + 'settings.rewrite_mentions_acct': 'Reescribir con nombre de usuarix y dominio (para cuentas remotas)', + 'settings.rewrite_mentions_no': 'No reescribir menciones', + 'settings.rewrite_mentions_username': 'Reescribir con nombre de usuarix', + 'settings.rewrite_mentions': 'Reescribir menciones in publicaciones mostradas', + 'settings.show_action_bar': 'Mostrar botones de acción en toots colapsados', + 'settings.show_content_type_choice': 'Mostrar selección de tipo de contenido al crear toots', + 'settings.show_reply_counter': 'Mostrar un conteo estimado de respuestas', + 'settings.side_arm_reply_mode.copy': 'Copiar opción de privacidad del toot al que estás respondiendo', + 'settings.side_arm_reply_mode.keep': 'Conservar opción de privacidad', + 'settings.side_arm_reply_mode.restrict': 'Restringir la opción de privacidad a la misma del toot al que estás respondiendo', + 'settings.side_arm_reply_mode': 'Al responder a un toot:', + 'settings.side_arm.none': 'Ninguno', + 'settings.side_arm': 'Botón secundario:', + 'settings.swipe_to_change_columns': 'Permitir deslizar para cambiar columnas (Sólo en móvil)', + 'settings.tag_misleading_links.hint': 'Añadir una indicación visual indicando el destino de los enlace que no los mencionen explícitamente', + 'settings.tag_misleading_links': 'Marcar enlaces engañosos', + 'settings.wide_view': 'Vista amplia (solo modo de escritorio)', + 'status.collapse': 'Colapsar', + 'status.uncollapse': 'Descolapsar', + 'confirmations.unfilter': 'Información del toot filtrado', + 'confirmations.unfilter.author': 'Publicado por', + 'confirmations.unfilter.filters': 'Coincidencia con {count, plural, one {filtro} other {filtros}}', + 'confirmations.unfilter.edit_filter': 'Editar filtro', + 'confirmations.unfilter.confirm': 'Mostrar', + 'confirmations.delete.confirm': 'Borrar', + 'confirmations.delete.message': 'Por favor confirma borrado', + 'confirmations.redraft.confirm': 'Borrar y volver a borrador', + 'confirmations.redraft.message': '¿Borrar y volver a borrador? Perderás todas las respuestas, retoots y favoritos asociados, y las respuestas a la publicación original quedarán huérfanos.', + 'confirmations.reply.confirm': 'Responder', + 'confirmations.reply.message': 'Responder no sobreescribirá el mensaje que estás escibiendo actualmente. ¿Deseas continuar?', + 'status.show_filter_reason': '(mostrar por qué)', }; export default Object.assign({}, inherited, messages); diff --git a/app/javascript/flavours/glitch/names.yml b/app/javascript/flavours/glitch/names.yml index 0801c4565..0d56fd3cc 100644 --- a/app/javascript/flavours/glitch/names.yml +++ b/app/javascript/flavours/glitch/names.yml @@ -13,3 +13,11 @@ pl: skins: glitch: default: Domyślny +es: + flavours: + glitch: + description: El diseño predeterminado para las instancias con GlitchSoc. + name: Glitchsoc + skins: + glitch: + default: Predeterminado diff --git a/app/javascript/flavours/glitch/packs/about.js b/app/javascript/flavours/glitch/packs/about.js index bc0a4887b..2e2cce501 100644 --- a/app/javascript/flavours/glitch/packs/about.js +++ b/app/javascript/flavours/glitch/packs/about.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; function loaded() { diff --git a/app/javascript/flavours/glitch/packs/common.js b/app/javascript/flavours/glitch/packs/common.js index 1fedc890a..7dc34eba9 100644 --- a/app/javascript/flavours/glitch/packs/common.js +++ b/app/javascript/flavours/glitch/packs/common.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import { start } from '@rails/ujs'; start(); diff --git a/app/javascript/flavours/glitch/packs/error.js b/app/javascript/flavours/glitch/packs/error.js index 81c86c3ab..9f692ad37 100644 --- a/app/javascript/flavours/glitch/packs/error.js +++ b/app/javascript/flavours/glitch/packs/error.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import ready from 'flavours/glitch/util/ready'; ready(() => { diff --git a/app/javascript/flavours/glitch/packs/home.js b/app/javascript/flavours/glitch/packs/home.js index b8f7b7d8e..d06688985 100644 --- a/app/javascript/flavours/glitch/packs/home.js +++ b/app/javascript/flavours/glitch/packs/home.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; loadPolyfills().then(() => { diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index b9b588dc6..c74e5c9af 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js index 8a9f23505..9c4d119c1 100644 --- a/app/javascript/flavours/glitch/packs/settings.js +++ b/app/javascript/flavours/glitch/packs/settings.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; import ready from 'flavours/glitch/util/ready'; import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions'; diff --git a/app/javascript/flavours/glitch/packs/share.js b/app/javascript/flavours/glitch/packs/share.js index 9f2aa2553..f4a97e201 100644 --- a/app/javascript/flavours/glitch/packs/share.js +++ b/app/javascript/flavours/glitch/packs/share.js @@ -1,3 +1,4 @@ +import 'packs/public-path'; import loadPolyfills from 'flavours/glitch/util/load_polyfills'; function loaded() { diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index cadbd01a3..b1ddb769e 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -38,6 +38,7 @@ import trends from './trends'; import announcements from './announcements'; import markers from './markers'; import account_notes from './account_notes'; +import picture_in_picture from './picture_in_picture'; const reducers = { announcements, @@ -79,6 +80,7 @@ const reducers = { trends, markers, account_notes, + picture_in_picture, }; export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 3d94d665c..ea37ae4aa 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -49,10 +49,13 @@ const initialState = ImmutableMap({ letterbox : true, fullwidth : true, reveal_behind_cw : false, + pop_in_player : true, + pop_in_position : 'right', }), notifications : ImmutableMap({ favicon_badge : false, tab_badge : true, + show_unread : true, }), }); diff --git a/app/javascript/flavours/glitch/reducers/mutes.js b/app/javascript/flavours/glitch/reducers/mutes.js index 7111bb710..d346d9a78 100644 --- a/app/javascript/flavours/glitch/reducers/mutes.js +++ b/app/javascript/flavours/glitch/reducers/mutes.js @@ -3,12 +3,14 @@ import Immutable from 'immutable'; import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, } from 'flavours/glitch/actions/mutes'; const initialState = Immutable.Map({ new: Immutable.Map({ account: null, notifications: true, + duration: 0, }), }); @@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) { }); case MUTES_TOGGLE_HIDE_NOTIFICATIONS: return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); default: return state; } diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index e136369ae..b4c5ef71a 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -17,6 +17,8 @@ import { NOTIFICATIONS_ENTER_CLEARING_MODE, NOTIFICATIONS_MARK_ALL_FOR_DELETE, NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, } from 'flavours/glitch/actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -44,6 +46,8 @@ const initialState = ImmutableMap({ isLoading: false, cleaningMode: false, isTabVisible: true, + browserSupport: false, + browserPermission: 'default', // notification removal mark of new notifs loaded whilst cleaningMode is true. markNewForDelete: false, }); @@ -185,7 +189,7 @@ const deleteMarkedNotifs = (state) => { const updateMounted = (state) => { state = state.update('mounted', count => count + 1); - if (!shouldCountUnreadNotifications(state)) { + if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) { state = state.set('readMarkerId', state.get('lastReadId')); state = clearUnread(state); } @@ -201,7 +205,7 @@ const updateVisibility = (state, visibility) => { return state; }; -const shouldCountUnreadNotifications = (state) => { +const shouldCountUnreadNotifications = (state, ignoreScroll = false) => { const isTabVisible = state.get('isTabVisible'); const isOnTop = state.get('top'); const isMounted = state.get('mounted') > 0; @@ -209,7 +213,7 @@ const shouldCountUnreadNotifications = (state) => { const lastItem = state.get('items').findLast(item => item !== null); const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); - return !(isTabVisible && isOnTop && isMounted && lastItemReached); + return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached); }; const recountUnread = (state, last_read_id) => { @@ -275,6 +279,10 @@ export default function notifications(state = initialState, action) { return action.timeline === 'home' ? state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); case NOTIFICATION_MARK_FOR_DELETE: return markForDelete(state, action.id, action.yes); diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js new file mode 100644 index 000000000..f552a59c2 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js @@ -0,0 +1,22 @@ +import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture'; + +const initialState = { + statusId: null, + accountId: null, + type: null, + src: null, + muted: false, + volume: 0, + currentTime: 0, +}; + +export default function pictureInPicture(state = initialState, action) { + switch(action.type) { + case PICTURE_IN_PICTURE_DEPLOY: + return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props }; + case PICTURE_IN_PICTURE_REMOVE: + return { ...initialState }; + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/reducers/relationships.js b/app/javascript/flavours/glitch/reducers/relationships.js index 33eb5b425..49dd77ef5 100644 --- a/app/javascript/flavours/glitch/reducers/relationships.js +++ b/app/javascript/flavours/glitch/reducers/relationships.js @@ -45,7 +45,7 @@ const initialState = ImmutableMap(); export default function relationships(state = initialState, action) { switch(action.type) { case ACCOUNT_FOLLOW_REQUEST: - return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); + return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); case ACCOUNT_FOLLOW_FAIL: return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); case ACCOUNT_UNFOLLOW_REQUEST: diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js index ef99ad552..bf0545c48 100644 --- a/app/javascript/flavours/glitch/reducers/settings.js +++ b/app/javascript/flavours/glitch/reducers/settings.js @@ -33,12 +33,13 @@ const initialState = ImmutableMap({ notifications: ImmutableMap({ alerts: ImmutableMap({ - follow: true, + follow: false, follow_request: false, - favourite: true, - reblog: true, - mention: true, - poll: true, + favourite: false, + reblog: false, + mention: false, + poll: false, + status: false, }), quickFilter: ImmutableMap({ @@ -54,6 +55,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), sounds: ImmutableMap({ @@ -63,6 +65,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), }), diff --git a/app/javascript/flavours/glitch/reducers/user_lists.js b/app/javascript/flavours/glitch/reducers/user_lists.js index 202f9198f..bfddbd246 100644 --- a/app/javascript/flavours/glitch/reducers/user_lists.js +++ b/app/javascript/flavours/glitch/reducers/user_lists.js @@ -53,14 +53,20 @@ import { } from 'flavours/glitch/actions/directory'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +const initialListState = ImmutableMap({ + next: null, + isLoading: false, + items: ImmutableList(), +}); + const initialState = ImmutableMap({ - followers: ImmutableMap(), - following: ImmutableMap(), - reblogged_by: ImmutableMap(), - favourited_by: ImmutableMap(), - follow_requests: ImmutableMap(), - blocks: ImmutableMap(), - mutes: ImmutableMap(), + followers: initialListState, + following: initialListState, + reblogged_by: initialListState, + favourited_by: initialListState, + follow_requests: initialListState, + blocks: initialListState, + mutes: initialListState, }); const normalizeList = (state, path, accounts, next) => { diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss index 145219fa7..c0bbe5633 100644 --- a/app/javascript/flavours/glitch/styles/components/accounts.scss +++ b/app/javascript/flavours/glitch/styles/components/accounts.scss @@ -254,127 +254,6 @@ text-align: center; } -.column-settings__outer { - background: lighten($ui-base-color, 8%); - padding: 15px; -} - -.column-settings__section { - color: $darker-text-color; - cursor: default; - display: block; - font-weight: 500; - margin-bottom: 10px; -} - -.column-settings__hashtags { - .column-settings__row { - margin-bottom: 15px; - } - - .column-select { - &__control { - @include search-input(); - - &::placeholder { - color: lighten($darker-text-color, 4%); - } - - &::-moz-focus-inner { - border: 0; - } - - &::-moz-focus-inner, - &:focus, - &:active { - outline: 0 !important; - } - - &:focus { - background: lighten($ui-base-color, 4%); - } - - @media screen and (max-width: 600px) { - font-size: 16px; - } - } - - &__placeholder { - color: $dark-text-color; - padding-left: 2px; - font-size: 12px; - } - - &__value-container { - padding-left: 6px; - } - - &__multi-value { - background: lighten($ui-base-color, 8%); - - &__remove { - cursor: pointer; - - &:hover, - &:active, - &:focus { - background: lighten($ui-base-color, 12%); - color: lighten($darker-text-color, 4%); - } - } - } - - &__multi-value__label, - &__input { - color: $darker-text-color; - } - - &__clear-indicator, - &__dropdown-indicator { - cursor: pointer; - transition: none; - color: $dark-text-color; - - &:hover, - &:active, - &:focus { - color: lighten($dark-text-color, 4%); - } - } - - &__indicator-separator { - background-color: lighten($ui-base-color, 8%); - } - - &__menu { - @include search-popout(); - padding: 0; - background: $ui-secondary-color; - } - - &__menu-list { - padding: 6px; - } - - &__option { - color: $inverted-text-color; - border-radius: 4px; - font-size: 14px; - - &--is-focused, - &--is-selected { - background: darken($ui-secondary-color, 10%); - } - } - } -} - -.column-settings__row { - .text-btn { - margin-bottom: 15px; - } -} - .relationship-tag { color: $primary-text-color; margin-bottom: 4px; diff --git a/app/javascript/flavours/glitch/styles/components/columns.scss b/app/javascript/flavours/glitch/styles/components/columns.scss index 6b657660a..be32ae52e 100644 --- a/app/javascript/flavours/glitch/styles/components/columns.scss +++ b/app/javascript/flavours/glitch/styles/components/columns.scss @@ -463,6 +463,15 @@ flex: 1; } +.column-header__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + .column-header__icon { display: inline-block; margin-right: 5px; @@ -560,3 +569,150 @@ margin: 0 5px; } } + +.column-settings__outer { + background: lighten($ui-base-color, 8%); + padding: 15px; +} + +.column-settings__section { + color: $darker-text-color; + cursor: default; + display: block; + font-weight: 500; + margin-bottom: 10px; +} + +.column-settings__row--with-margin { + margin-bottom: 15px; +} + +.column-settings__hashtags { + .column-settings__row { + margin-bottom: 15px; + } + + .column-select { + &__control { + @include search-input(); + + &::placeholder { + color: lighten($darker-text-color, 4%); + } + + &::-moz-focus-inner { + border: 0; + } + + &::-moz-focus-inner, + &:focus, + &:active { + outline: 0 !important; + } + + &:focus { + background: lighten($ui-base-color, 4%); + } + + @media screen and (max-width: 600px) { + font-size: 16px; + } + } + + &__placeholder { + color: $dark-text-color; + padding-left: 2px; + font-size: 12px; + } + + &__value-container { + padding-left: 6px; + } + + &__multi-value { + background: lighten($ui-base-color, 8%); + + &__remove { + cursor: pointer; + + &:hover, + &:active, + &:focus { + background: lighten($ui-base-color, 12%); + color: lighten($darker-text-color, 4%); + } + } + } + + &__multi-value__label, + &__input { + color: $darker-text-color; + } + + &__clear-indicator, + &__dropdown-indicator { + cursor: pointer; + transition: none; + color: $dark-text-color; + + &:hover, + &:active, + &:focus { + color: lighten($dark-text-color, 4%); + } + } + + &__indicator-separator { + background-color: lighten($ui-base-color, 8%); + } + + &__menu { + @include search-popout(); + padding: 0; + background: $ui-secondary-color; + } + + &__menu-list { + padding: 6px; + } + + &__option { + color: $inverted-text-color; + border-radius: 4px; + font-size: 14px; + + &--is-focused, + &--is-selected { + background: darken($ui-secondary-color, 10%); + } + } + } +} + +.column-settings__row { + .text-btn { + margin-bottom: 15px; + } +} + +.notifications-permission-banner { + padding: 30px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + text-align: center; + } + + p { + color: $darker-text-color; + margin-bottom: 15px; + text-align: center; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 04266c497..0614278e2 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -153,6 +153,7 @@ cursor: pointer; transition: all 100ms ease-in; transition-property: background-color, color; + text-decoration: none; &:hover, &:active, @@ -226,6 +227,20 @@ background: rgba($base-overlay-background, 0.9); } } + + &--with-counter { + display: inline-flex; + align-items: center; + width: auto !important; + } + + &__counter { + display: inline-block; + width: 14px; + margin-left: 4px; + font-size: 12px; + font-weight: 500; + } } .text-icon-button { @@ -708,6 +723,17 @@ line-height: 14px; color: $primary-text-color; } + + &__issue-badge { + position: absolute; + left: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } } .column-link--transparent .icon-with-badge__badge { diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index d0be730ac..85f216887 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -785,6 +785,22 @@ } } } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } } .confirmation-modal__container, diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index b70ff00f1..03acd72d8 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -572,28 +572,14 @@ align-items: center; display: flex; margin-top: 8px; - - &__counter { - display: inline-flex; - margin-right: 11px; - align-items: center; - - .status__action-bar-button { - margin-right: 4px; - } - - &__label { - display: inline-block; - width: 14px; - font-size: 12px; - font-weight: 500; - color: $action-button-color; - } - } } .status__action-bar-button { margin-right: 18px; + + &.icon-button--with-counter { + margin-right: 14px; + } } .status__action-bar-dropdown { @@ -1081,3 +1067,105 @@ a.status-card.compact:hover { } } } + +.picture-in-picture { + position: fixed; + bottom: 20px; + right: 20px; + width: 300px; + + &.left { + right: unset; + left: 20px; + } + + &__footer { + border-radius: 0 0 4px 4px; + background: lighten($ui-base-color, 4%); + padding: 10px; + padding-top: 12px; + display: flex; + justify-content: space-between; + } + + &__header { + border-radius: 4px 4px 0 0; + background: lighten($ui-base-color, 4%); + padding: 10px; + display: flex; + justify-content: space-between; + + &__account { + display: flex; + text-decoration: none; + } + + .account__avatar { + margin-right: 10px; + } + + .display-name { + color: $primary-text-color; + text-decoration: none; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + span { + color: $darker-text-color; + } + } + } + + .video-player, + .audio-player { + border-radius: 0; + } + + @media screen and (max-width: 415px) { + width: 210px; + bottom: 10px; + right: 10px; + + &__footer { + display: none; + } + + .video-player, + .audio-player { + border-radius: 0 0 4px 4px; + } + } +} + +.picture-in-picture-placeholder { + box-sizing: border-box; + border: 2px dashed lighten($ui-base-color, 8%); + background: $base-shadow-color; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 10px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + color: $darker-text-color; + + i { + display: block; + font-size: 24px; + font-weight: 400; + margin-bottom: 10px; + } + + &:hover, + &:focus, + &:active { + border-color: lighten($ui-base-color, 12%); + } +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index e5a5cc246..163b5220c 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -385,3 +385,8 @@ .directory__tag > div { box-shadow: none; } + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px; +} diff --git a/app/javascript/flavours/glitch/util/config.js b/app/javascript/flavours/glitch/util/config.js new file mode 100644 index 000000000..c3e2b73ae --- /dev/null +++ b/app/javascript/flavours/glitch/util/config.js @@ -0,0 +1,10 @@ +import ready from './ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js index 233ec25e3..43ff4661c 100644 --- a/app/javascript/flavours/glitch/util/emoji/index.js +++ b/app/javascript/flavours/glitch/util/emoji/index.js @@ -1,11 +1,10 @@ import { autoPlayGif, useSystemEmojiFont } from 'flavours/glitch/util/initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'flavours/glitch/util/config'; import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const assetHost = process.env.CDN_HOST || ''; - // Convert to file names from emojis. (For different variation selector emojis) const emojiFilenames = (emojis) => { return emojis.map(v => unicodeMapping[v].filename); diff --git a/app/javascript/flavours/glitch/util/main.js b/app/javascript/flavours/glitch/util/main.js index 1fdb9ff2b..6577b70c2 100644 --- a/app/javascript/flavours/glitch/util/main.js +++ b/app/javascript/flavours/glitch/util/main.js @@ -1,4 +1,5 @@ import * as registerPushNotifications from 'flavours/glitch/actions/push_notifications'; +import { setupBrowserNotifications } from 'flavours/glitch/actions/notifications'; import { default as Mastodon, store } from 'flavours/glitch/containers/mastodon'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -22,6 +23,7 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(<Mastodon {...props} />, mountNode); + store.dispatch(setupBrowserNotifications()); if (process.env.NODE_ENV === 'production') { // avoid offline in dev mode because it's harder to debug require('offline-plugin/runtime').install(); diff --git a/app/javascript/flavours/glitch/util/notifications.js b/app/javascript/flavours/glitch/util/notifications.js new file mode 100644 index 000000000..ab119c2e3 --- /dev/null +++ b/app/javascript/flavours/glitch/util/notifications.js @@ -0,0 +1,29 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/flavours/vanilla/names.yml b/app/javascript/flavours/vanilla/names.yml index 1b3dab9c9..8749f2eda 100644 --- a/app/javascript/flavours/vanilla/names.yml +++ b/app/javascript/flavours/vanilla/names.yml @@ -14,3 +14,11 @@ pl: skins: vanilla: default: Domyślny +es: + flavours: + vanilla: + description: El diseño predeterminado en las instancias de Mastodon. Puede que no soporte todas las características de GlitchSoc. + name: Mastodon Original + skins: + vanilla: + default: Predeterminado diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 723c04e55..58b636602 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -257,11 +257,11 @@ export function unblockAccountFail(error) { }; -export function muteAccount(id, notifications) { +export function muteAccount(id, notifications, duration=0) { return (dispatch, getState) => { dispatch(muteAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => { + api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => { // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); }).catch(error => { diff --git a/app/javascript/mastodon/actions/markers.js b/app/javascript/mastodon/actions/markers.js index 41a95503e..c4b61effd 100644 --- a/app/javascript/mastodon/actions/markers.js +++ b/app/javascript/mastodon/actions/markers.js @@ -100,8 +100,12 @@ export function submitMarkersSuccess({ home, notifications }) { }; }; -export function submitMarkers() { - return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); +export function submitMarkers(params = {}) { + const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { + debouncedSubmitMarkers.flush(); + } + return result; }; export const fetchMarkers = () => (dispatch, getState) => { diff --git a/app/javascript/mastodon/actions/mutes.js b/app/javascript/mastodon/actions/mutes.js index 9f645faee..d8874f353 100644 --- a/app/javascript/mastodon/actions/mutes.js +++ b/app/javascript/mastodon/actions/mutes.js @@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; +export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION'; export function fetchMutes() { return (dispatch, getState) => { @@ -104,3 +105,12 @@ export function toggleHideNotifications() { dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); }; } + +export function changeMuteDuration(duration) { + return dispatch => { + dispatch({ + type: MUTES_CHANGE_DURATION, + duration, + }); + }; +} diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 552def63b..c4fa66428 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import compareId from 'mastodon/compare_id'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; +import { requestNotificationPermission } from '../utils/notifications'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; @@ -33,8 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; + export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; +export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, @@ -234,3 +239,47 @@ export const mountNotifications = () => ({ export const unmountNotifications = () => ({ type: NOTIFICATIONS_UNMOUNT, }); + + +export const markNotificationsAsRead = () => ({ + type: NOTIFICATIONS_MARK_AS_READ, +}); + +// Browser support +export function setupBrowserNotifications() { + return dispatch => { + dispatch(setBrowserSupport('Notification' in window)); + if ('Notification' in window) { + dispatch(setBrowserPermission(Notification.permission)); + } + + if ('Notification' in window && 'permissions' in navigator) { + navigator.permissions.query({ name: 'notifications' }).then((status) => { + status.onchange = () => dispatch(setBrowserPermission(Notification.permission)); + }); + } + }; +} + +export function requestBrowserPermission(callback = noOp) { + return dispatch => { + requestNotificationPermission((permission) => { + dispatch(setBrowserPermission(permission)); + callback(permission); + }); + }; +}; + +export function setBrowserSupport (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_SUPPORT, + value, + }; +} + +export function setBrowserPermission (value) { + return { + type: NOTIFICATIONS_SET_BROWSER_PERMISSION, + value, + }; +} diff --git a/app/javascript/mastodon/actions/onboarding.js b/app/javascript/mastodon/actions/onboarding.js index a1dd3a731..42d8ea33f 100644 --- a/app/javascript/mastodon/actions/onboarding.js +++ b/app/javascript/mastodon/actions/onboarding.js @@ -1,8 +1,21 @@ import { changeSetting, saveSettings } from './settings'; +import { requestBrowserPermission } from './notifications'; export const INTRODUCTION_VERSION = 20181216044202; export const closeOnboarding = () => dispatch => { dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(saveSettings()); + + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', 'alerts', 'follow'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'mention'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'poll'], true)); + dispatch(changeSetting(['notifications', 'alerts', 'status'], true)); + dispatch(saveSettings()); + } + })); }; diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 2705a6001..0e40ee1d6 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -8,6 +8,7 @@ import IconButton from './icon_button'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { me } from '../initial_state'; +import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent { } } + let mute_expires_at; + if (account.get('mute_expires_at')) { + mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; + } + return ( <div className='account'> <div className='account__wrapper'> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + {mute_expires_at} <DisplayName account={account} /> </Permalink> diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js index ce4383a60..4937e4d98 100644 --- a/app/javascript/mastodon/components/autosuggest_emoji.js +++ b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -1,8 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; - -const assetHost = process.env.CDN_HOST || ''; +import { assetHost } from 'mastodon/utils/config'; export default class AutosuggestEmoji extends React.PureComponent { diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 1bb583583..236e92296 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent { onMove: PropTypes.func, onClick: PropTypes.func, appendContent: PropTypes.node, + collapseIssues: PropTypes.bool, }; state = { @@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent { } render () { - const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props; + const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props; const { collapsed, animating } = this.state; const wrapperClassName = classNames('column-header__wrapper', { @@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent { } if (children || (multiColumn && this.props.onPin)) { - collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>; + collapseButton = ( + <button + className={collapsibleButtonClassName} + title={formatMessage(collapsed ? messages.show : messages.hide)} + aria-label={formatMessage(collapsed ? messages.show : messages.hide)} + aria-pressed={collapsed ? 'false' : 'true'} + onClick={this.handleToggleClick} + > + <i className='icon-with-badge'> + <Icon id='sliders' /> + {collapseIssues && <i className='icon-with-badge__issue-badge' />} + </i> + </button> + ); } const hasTitle = icon && title; diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 7f83dc1b9..7ec39198a 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -116,6 +116,7 @@ export default class IconButton extends React.PureComponent { activate, deactivate, overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', }); if (typeof counter !== 'undefined') { diff --git a/app/javascript/mastodon/components/icon_with_badge.js b/app/javascript/mastodon/components/icon_with_badge.js index 7851eb4be..4214eccfd 100644 --- a/app/javascript/mastodon/components/icon_with_badge.js +++ b/app/javascript/mastodon/components/icon_with_badge.js @@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon'; const formatNumber = num => num > 40 ? '40+' : num; -const IconWithBadge = ({ id, count, className }) => ( +const IconWithBadge = ({ id, count, issueBadge, className }) => ( <i className='icon-with-badge'> <Icon id={id} fixedWidth className={className} /> {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} + {issueBadge && <i className='icon-with-badge__issue-badge' />} </i> ); IconWithBadge.propTypes = { id: PropTypes.string.isRequired, count: PropTypes.number.isRequired, + issueBadge: PropTypes.bool, className: PropTypes.string, }; diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index e8a36a923..bac136e5e 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -25,7 +26,6 @@ const messages = defineMessages({ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, }); -const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 5d9dad097..4e37f3a80 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,11 +1,10 @@ import { autoPlayGif } from '../../initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'mastodon/utils/config'; import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const assetHost = process.env.CDN_HOST || ''; - // Convert to file names from emojis. (For different variation selector emojis) const emojiFilenames = (emojis) => { return emojis.map(v => unicodeMapping[v].filename); diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index 1896994da..4853c3935 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import IconButton from 'mastodon/components/icon_button'; import Icon from 'mastodon/components/icon'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; -import { autoPlayGif, reduceMotion } from 'mastodon/initial_state'; +import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state'; import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; import { mascot } from 'mastodon/initial_state'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; @@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick import AnimatedNumber from 'mastodon/components/animated_number'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent { } -const assetHost = process.env.CDN_HOST || ''; - class Emoji extends React.PureComponent { static propTypes = { @@ -436,6 +435,7 @@ class Announcements extends ImmutablePureComponent { removeReaction={this.props.removeReaction} intl={intl} selected={index === idx} + disabled={disableSwiping} /> ))} </ReactSwipeableViews> diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 02e1bbba5..1b9994612 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { me, profile_directory, showTrends } from '../../initial_state'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { List as ImmutableList } from 'immutable'; -import NavigationBar from '../compose/components/navigation_bar'; +import NavigationContainer from '../compose/containers/navigation_container'; import Icon from 'mastodon/components/icon'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; import TrendsContainer from './containers/trends_container'; @@ -168,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent { <div className='getting-started'> <div className='getting-started__wrapper' style={{ height }}> - {!multiColumn && <NavigationBar account={myAccount} />} + {!multiColumn && <NavigationContainer />} {navItems} </div> diff --git a/app/javascript/mastodon/features/introduction/index.js b/app/javascript/mastodon/features/introduction/index.js index 754477bb9..5820750a4 100644 --- a/app/javascript/mastodon/features/introduction/index.js +++ b/app/javascript/mastodon/features/introduction/index.js @@ -9,6 +9,7 @@ import screenHello from '../../../images/screen_hello.svg'; import screenFederation from '../../../images/screen_federation.svg'; import screenInteractions from '../../../images/screen_interactions.svg'; import logoTransparent from '../../../images/logo_transparent.svg'; +import { disableSwiping } from 'mastodon/initial_state'; const FrameWelcome = ({ domain, onNext }) => ( <div className='introduction__frame'> @@ -171,7 +172,7 @@ class Introduction extends React.PureComponent { return ( <div className='introduction'> - <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='introduction__pager'> + <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} disabled={disableSwiping} className='introduction__pager'> {pages.map((page, i) => ( <div key={i} className={classNames('introduction__frame-wrapper', { 'active': i === currentIndex })}>{page}</div> ))} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 8bd03fbda..169e4b44d 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -12,6 +12,10 @@ export default class ColumnSettings extends React.PureComponent { pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, + onRequestNotificationPermission: PropTypes.func, + alertsEnabled: PropTypes.bool, + browserSupport: PropTypes.bool, + browserPermission: PropTypes.bool, }; onPushChange = (path, checked) => { @@ -19,7 +23,7 @@ export default class ColumnSettings extends React.PureComponent { } render () { - const { settings, pushSettings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear, alertsEnabled, browserSupport, browserPermission } = this.props; const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />; const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />; @@ -32,6 +36,12 @@ export default class ColumnSettings extends React.PureComponent { return ( <div> + {alertsEnabled && browserSupport && browserPermission === 'denied' && ( + <div className='column-settings__row column-settings__row--with-margin'> + <span className='warning-hint'><FormattedMessage id='notifications.permission_denied' defaultMessage='Desktop notifications are unavailable due to previously denied browser permissions request' /></span> + </div> + )} + <div className='column-settings__row'> <ClearColumnButton onClick={onClear} /> </div> @@ -40,6 +50,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-filter-bar' className='column-settings__section'> <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' /> </span> + <div className='column-settings__row'> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} /> <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} /> @@ -50,7 +61,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} /> @@ -61,7 +72,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} onChange={onChange} label={soundStr} /> @@ -72,7 +83,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> @@ -83,7 +94,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'mention']} onChange={onChange} label={soundStr} /> @@ -94,7 +105,7 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> @@ -105,12 +116,23 @@ export default class ColumnSettings extends React.PureComponent { <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span> <div className='column-settings__row'> - <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} /> {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />} <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} /> <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} /> </div> </div> + + <div role='group' aria-labelledby='notifications-status'> + <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.status' defaultMessage='New toots:' /></span> + + <div className='column-settings__row'> + <SettingToggle disabled={browserPermission !== 'granted'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'status']} onChange={onChange} label={alertStr} /> + {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'status']} onChange={this.onPushChange} label={pushStr} />} + <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'status']} onChange={onChange} label={showStr} /> + <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'status']} onChange={onChange} label={soundStr} /> + </div> + </div> </div> ); } diff --git a/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js new file mode 100644 index 000000000..766c9bb5b --- /dev/null +++ b/app/javascript/mastodon/features/notifications/components/notifications_permission_banner.js @@ -0,0 +1,30 @@ +import React from 'react'; +import Icon from 'mastodon/components/icon'; +import Button from 'mastodon/components/button'; +import { requestBrowserPermission } from 'mastodon/actions/notifications'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +export default @connect(() => {}) +class NotificationsPermissionBanner extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + }; + + handleClick = () => { + this.props.dispatch(requestBrowserPermission()); + } + + render () { + return ( + <div className='notifications-permission-banner'> + <h2><FormattedMessage id='notifications_permission_banner.title' defaultMessage='Never miss a thing' /></h2> + <p><FormattedMessage id='notifications_permission_banner.how_to_control' defaultMessage="To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled." values={{ icon: <Icon id='sliders' /> }} /></p> + <Button onClick={this.handleClick}><FormattedMessage id='notifications_permission_banner.enable' defaultMessage='Enable desktop notifications' /></Button> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index e6f593ef8..c4c8bffbe 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -12,6 +12,7 @@ export default class SettingToggle extends React.PureComponent { label: PropTypes.node.isRequired, onChange: PropTypes.func.isRequired, defaultValue: PropTypes.bool, + disabled: PropTypes.bool, } onChange = ({ target }) => { @@ -19,12 +20,12 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingPath, label, defaultValue } = this.props; + const { prefix, settings, settingPath, label, defaultValue, disabled } = this.props; const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); return ( <div className='setting-toggle'> - <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> + <Toggle disabled={disabled} id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> <label htmlFor={id} className='setting-toggle__label'>{label}</label> </div> ); diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index a67f26295..9a70bd4f3 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,28 +3,55 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting } from '../../../actions/settings'; import { setFilter } from '../../../actions/notifications'; -import { clearNotifications } from '../../../actions/notifications'; +import { clearNotifications, requestBrowserPermission } from '../../../actions/notifications'; import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; +import { showAlert } from '../../../actions/alerts'; const messages = defineMessages({ clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' }, clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }, + permissionDenied: { id: 'notifications.permission_denied_alert', defaultMessage: 'Desktop notifications can\'t be enabled, as browser permission has been denied before' }, }); const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), pushSettings: state.get('push_notifications'), + alertsEnabled: state.getIn(['settings', 'notifications', 'alerts']).includes(true), + browserSupport: state.getIn(['notifications', 'browserSupport']), + browserPermission: state.getIn(['notifications', 'browserPermission']), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (path, checked) { if (path[0] === 'push') { - dispatch(changePushNotifications(path.slice(1), checked)); + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changePushNotifications(path.slice(1), checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changePushNotifications(path.slice(1), checked)); + } } else if (path[0] === 'quickFilter') { dispatch(changeSetting(['notifications', ...path], checked)); dispatch(setFilter('all')); + } else if (path[0] === 'alerts' && checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + if (checked && typeof window.Notification !== 'undefined' && Notification.permission !== 'granted') { + dispatch(requestBrowserPermission((permission) => { + if (permission === 'granted') { + dispatch(changeSetting(['notifications', ...path], checked)); + } else { + dispatch(showAlert(undefined, messages.permissionDenied)); + } + })); + } else { + dispatch(changeSetting(['notifications', ...path], checked)); + } } else { dispatch(changeSetting(['notifications', ...path], checked)); } @@ -38,6 +65,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onRequestNotificationPermission () { + dispatch(requestBrowserPermission()); + }, + }); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings)); diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 41a45b2b6..73df7f49d 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -12,6 +12,7 @@ import { unmountNotifications, markNotificationsAsRead, } from '../../actions/notifications'; +import { submitMarkers } from '../../actions/markers'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -24,6 +25,7 @@ import ScrollableList from '../../components/scrollable_list'; import LoadGap from '../../components/load_gap'; import Icon from 'mastodon/components/icon'; import compareId from 'mastodon/compare_id'; +import NotificationsPermissionBanner from './components/notifications_permission_banner'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -54,6 +56,7 @@ const mapStateToProps = state => ({ numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, lastReadId: state.getIn(['notifications', 'readMarkerId']), canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), + needsNotificationPermission: state.getIn(['settings', 'notifications', 'alerts']).includes(true) && state.getIn(['notifications', 'browserSupport']) && state.getIn(['notifications', 'browserPermission']) === 'default', }); export default @connect(mapStateToProps) @@ -74,6 +77,7 @@ class Notifications extends React.PureComponent { numPending: PropTypes.number, lastReadId: PropTypes.string, canMarkAsRead: PropTypes.bool, + needsNotificationPermission: PropTypes.bool, }; static defaultProps = { @@ -162,10 +166,11 @@ class Notifications extends React.PureComponent { handleMarkAsRead = () => { this.props.dispatch(markNotificationsAsRead()); + this.props.dispatch(submitMarkers({ immediate: true })); }; render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead, needsNotificationPermission } = this.props; const pinned = !!columnId; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; @@ -209,6 +214,8 @@ class Notifications extends React.PureComponent { showLoading={isLoading && notifications.size === 0} hasMore={hasMore} numPending={numPending} + prepend={needsNotificationPermission && <NotificationsPermissionBanner />} + alwaysPrepend emptyMessage={emptyMessage} onLoadMore={this.handleLoadOlder} onLoadPending={this.handleLoadPending} diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 9b03cf26d..ecc0b8f0b 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -8,6 +8,8 @@ import ReactSwipeableViews from 'react-swipeable-views'; import TabsBar, { links, getIndex, getLink } from './tabs_bar'; import { Link } from 'react-router-dom'; +import { disableSwiping } from 'mastodon/initial_state'; + import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; @@ -185,7 +187,7 @@ class ColumnsArea extends ImmutablePureComponent { 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)}><Icon id='pencil' /></Link>; const content = columnIndex !== -1 ? ( - <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}> + <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={disableSwiping}> {links.map(this.renderView)} </ReactSwipeableViews> ) : ( diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 926a495d1..e19f277d8 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -20,6 +20,7 @@ import GIFV from 'mastodon/components/gifv'; import { me } from 'mastodon/initial_state'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); -const assetHost = process.env.CDN_HOST || ''; - class ImageLoader extends React.PureComponent { static propTypes = { diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index a02e3bbd7..54ec51fcf 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; import Icon from 'mastodon/components/icon'; import GIFV from 'mastodon/components/gifv'; +import { disableSwiping } from 'mastodon/initial_state'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -212,6 +213,7 @@ class MediaModal extends ImmutablePureComponent { containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index} + disabled={disableSwiping} > {content} </ReactSwipeableViews> diff --git a/app/javascript/mastodon/features/ui/components/mute_modal.js b/app/javascript/mastodon/features/ui/components/mute_modal.js index 852830c3c..d8d8e68c3 100644 --- a/app/javascript/mastodon/features/ui/components/mute_modal.js +++ b/app/javascript/mastodon/features/ui/components/mute_modal.js @@ -1,25 +1,32 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Toggle from 'react-toggle'; import Button from '../../../components/button'; import { closeModal } from '../../../actions/modal'; import { muteAccount } from '../../../actions/accounts'; -import { toggleHideNotifications } from '../../../actions/mutes'; +import { toggleHideNotifications, changeMuteDuration } from '../../../actions/mutes'; +const messages = defineMessages({ + minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' }, + hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' }, + days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' }, + indefinite: { id: 'mute_modal.indefinite', defaultMessage: 'Indefinite' }, +}); const mapStateToProps = state => { return { account: state.getIn(['mutes', 'new', 'account']), notifications: state.getIn(['mutes', 'new', 'notifications']), + muteDuration: state.getIn(['mutes', 'new', 'duration']), }; }; const mapDispatchToProps = dispatch => { return { - onConfirm(account, notifications) { - dispatch(muteAccount(account.get('id'), notifications)); + onConfirm(account, notifications, muteDuration) { + dispatch(muteAccount(account.get('id'), notifications, muteDuration)); }, onClose() { @@ -29,6 +36,10 @@ const mapDispatchToProps = dispatch => { onToggleNotifications() { dispatch(toggleHideNotifications()); }, + + onChangeMuteDuration(e) { + dispatch(changeMuteDuration(e.target.value)); + }, }; }; @@ -43,6 +54,8 @@ class MuteModal extends React.PureComponent { onConfirm: PropTypes.func.isRequired, onToggleNotifications: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + muteDuration: PropTypes.number.isRequired, + onChangeMuteDuration: PropTypes.func.isRequired, }; componentDidMount() { @@ -51,7 +64,7 @@ class MuteModal extends React.PureComponent { handleClick = () => { this.props.onClose(); - this.props.onConfirm(this.props.account, this.props.notifications); + this.props.onConfirm(this.props.account, this.props.notifications, this.props.muteDuration); } handleCancel = () => { @@ -66,8 +79,12 @@ class MuteModal extends React.PureComponent { this.props.onToggleNotifications(); } + changeMuteDuration = (e) => { + this.props.onChangeMuteDuration(e); + } + render () { - const { account, notifications } = this.props; + const { account, notifications, muteDuration, intl } = this.props; return ( <div className='modal-root__modal mute-modal'> @@ -91,6 +108,21 @@ class MuteModal extends React.PureComponent { <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' /> </label> </div> + <div> + <span><FormattedMessage id='mute_modal.duration' defaultMessage='Duration' />: </span> + + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + <select value={muteDuration} onChange={this.changeMuteDuration}> + <option value={0}>{intl.formatMessage(messages.indefinite)}</option> + <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option> + <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option> + <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option> + <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option> + <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option> + <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option> + <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option> + </select> + </div> </div> <div className='mute-modal__action-bar'> diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index d05133507..c6df49a5f 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -266,7 +266,7 @@ class UI extends React.PureComponent { handleWindowFocus = () => { this.props.dispatch(focusApp()); - this.props.dispatch(submitMarkers()); + this.props.dispatch(submitMarkers({ immediate: true })); } handleWindowBlur = () => { @@ -366,10 +366,6 @@ class UI extends React.PureComponent { navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage); } - if (typeof window.Notification !== 'undefined' && Notification.permission === 'default') { - window.setTimeout(() => Notification.requestPermission(), 120 * 1000); - } - this.props.dispatch(fetchMarkers()); this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandNotifications()); @@ -379,7 +375,7 @@ class UI extends React.PureComponent { componentDidMount () { this.hotkeys.__mousetrap__.stopCallback = (e, element) => { - return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; + return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName); }; } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 847d29dea..89b59051c 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -26,5 +26,6 @@ export const usePendingItems = getMeta('use_pending_items'); export const showTrends = getMeta('trends'); export const title = getMeta('title'); export const cropImages = getMeta('crop_images'); +export const disableSwiping = getMeta('disable_swiping'); export default initialState; diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index e519f665d..d8c04854e 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -168,10 +168,18 @@ { "descriptors": [ { + "defaultMessage": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.", + "id": "error.unexpected_crash.explanation_addons" + }, + { "defaultMessage": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.", "id": "error.unexpected_crash.explanation" }, { + "defaultMessage": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", + "id": "error.unexpected_crash.next_steps_addons" + }, + { "defaultMessage": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "id": "error.unexpected_crash.next_steps" }, @@ -268,6 +276,15 @@ { "descriptors": [ { + "defaultMessage": "Put it back", + "id": "picture_in_picture.restore" + } + ], + "path": "app/javascript/mastodon/components/picture_in_picture_placeholder.json" + }, + { + "descriptors": [ + { "defaultMessage": "Closed", "id": "poll.closed" }, @@ -636,6 +653,15 @@ { "descriptors": [ { + "defaultMessage": "Profile unavailable", + "id": "empty_column.account_unavailable" + } + ], + "path": "app/javascript/mastodon/features/account_gallery/index.json" + }, + { + "descriptors": [ + { "defaultMessage": "Toots", "id": "account.posts" }, @@ -797,6 +823,14 @@ "id": "account.show_reblogs" }, { + "defaultMessage": "Notify me when @{name} posts", + "id": "account.enable_notifications" + }, + { + "defaultMessage": "Stop notifying me when @{name} posts", + "id": "account.disable_notifications" + }, + { "defaultMessage": "Pinned toots", "id": "navigation_bar.pins" }, @@ -2126,6 +2160,18 @@ "id": "confirmations.delete_list.confirm" }, { + "defaultMessage": "Any followed user", + "id": "lists.replies_policy.all_replies" + }, + { + "defaultMessage": "No one", + "id": "lists.replies_policy.no_replies" + }, + { + "defaultMessage": "Members of the list", + "id": "lists.replies_policy.list_replies" + }, + { "defaultMessage": "Edit list", "id": "lists.edit" }, @@ -2134,6 +2180,10 @@ "id": "lists.delete" }, { + "defaultMessage": "Show replies to:", + "id": "lists.replies_policy.title" + }, + { "defaultMessage": "There is nothing in this list yet. When members of this list post new statuses, they will appear here.", "id": "empty_column.list" } @@ -2219,6 +2269,10 @@ "id": "notifications.column_settings.push" }, { + "defaultMessage": "Desktop notifications are unavailable due to previously denied browser permissions request", + "id": "notifications.permission_denied" + }, + { "defaultMessage": "Quick filter bar", "id": "notifications.column_settings.filter_bar.category" }, @@ -2245,6 +2299,10 @@ { "defaultMessage": "Poll results:", "id": "notifications.column_settings.poll" + }, + { + "defaultMessage": "New toots:", + "id": "notifications.column_settings.status" } ], "path": "app/javascript/mastodon/features/notifications/components/column_settings.json" @@ -2272,6 +2330,10 @@ "id": "notifications.filter.follows" }, { + "defaultMessage": "Updates from people you follow", + "id": "notifications.filter.statuses" + }, + { "defaultMessage": "All", "id": "notifications.filter.all" } @@ -2314,6 +2376,10 @@ "id": "notification.reblog" }, { + "defaultMessage": "{name} just posted", + "id": "notification.status" + }, + { "defaultMessage": "{name} has requested to follow you", "id": "notification.follow_request" } @@ -2323,12 +2389,33 @@ { "descriptors": [ { + "defaultMessage": "Never miss a thing", + "id": "notifications_permission_banner.title" + }, + { + "defaultMessage": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", + "id": "notifications_permission_banner.how_to_control" + }, + { + "defaultMessage": "Enable desktop notifications", + "id": "notifications_permission_banner.enable" + } + ], + "path": "app/javascript/mastodon/features/notifications/components/notifications_permission_banner.json" + }, + { + "descriptors": [ + { "defaultMessage": "Are you sure you want to permanently clear all your notifications?", "id": "notifications.clear_confirmation" }, { "defaultMessage": "Clear notifications", "id": "notifications.clear" + }, + { + "defaultMessage": "Desktop notifications can't be enabled, as browser permission has been denied before", + "id": "notifications.permission_denied_alert" } ], "path": "app/javascript/mastodon/features/notifications/containers/column_settings_container.json" @@ -2340,6 +2427,10 @@ "id": "column.notifications" }, { + "defaultMessage": "Mark every notification as read", + "id": "notifications.mark_as_read" + }, + { "defaultMessage": "You don't have any notifications yet. Interact with others to start the conversation.", "id": "empty_column.notifications" } @@ -2349,6 +2440,47 @@ { "descriptors": [ { + "defaultMessage": "Reply", + "id": "status.reply" + }, + { + "defaultMessage": "Reply to thread", + "id": "status.replyAll" + }, + { + "defaultMessage": "Boost", + "id": "status.reblog" + }, + { + "defaultMessage": "Boost with original visibility", + "id": "status.reblog_private" + }, + { + "defaultMessage": "Unboost", + "id": "status.cancel_reblog_private" + }, + { + "defaultMessage": "This post cannot be boosted", + "id": "status.cannot_reblog" + }, + { + "defaultMessage": "Favourite", + "id": "status.favourite" + }, + { + "defaultMessage": "Reply", + "id": "confirmations.reply.confirm" + }, + { + "defaultMessage": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", + "id": "confirmations.reply.message" + } + ], + "path": "app/javascript/mastodon/features/picture_in_picture/components/footer.json" + }, + { + "descriptors": [ + { "defaultMessage": "Pinned toot", "id": "column.pins" } @@ -2799,6 +2931,14 @@ "id": "upload_form.description" }, { + "defaultMessage": "Analyzing picture…", + "id": "upload_modal.analyzing_picture" + }, + { + "defaultMessage": "Preparing OCR…", + "id": "upload_modal.preparing_ocr" + }, + { "defaultMessage": "Edit media", "id": "upload_modal.edit_media" }, @@ -2811,10 +2951,6 @@ "id": "upload_form.thumbnail" }, { - "defaultMessage": "Analyzing picture…", - "id": "upload_modal.analyzing_picture" - }, - { "defaultMessage": "Detect text from picture", "id": "upload_modal.detect_text" }, @@ -2911,6 +3047,22 @@ { "descriptors": [ { + "defaultMessage": "{number, plural, one {# minute} other {# minutes}}", + "id": "intervals.full.minutes" + }, + { + "defaultMessage": "{number, plural, one {# hour} other {# hours}}", + "id": "intervals.full.hours" + }, + { + "defaultMessage": "{number, plural, one {# day} other {# days}}", + "id": "intervals.full.days" + }, + { + "defaultMessage": "Indefinite", + "id": "mute_modal.indefinite" + }, + { "defaultMessage": "Are you sure you want to mute {name}?", "id": "confirmations.mute.message" }, @@ -2923,6 +3075,10 @@ "id": "mute_modal.hide_notifications" }, { + "defaultMessage": "Duration", + "id": "mute_modal.duration" + }, + { "defaultMessage": "Cancel", "id": "confirmation_modal.cancel" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 4d4bf7277..5b7fbaffd 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -9,8 +9,10 @@ "account.browse_more_on_origin_server": "Browse more on the original profile", "account.cancel_follow_request": "Cancel follow request", "account.direct": "Direct message @{name}", + "account.disable_notifications": "Stop notifying me when @{name} posts", "account.domain_blocked": "Domain blocked", "account.edit_profile": "Edit profile", + "account.enable_notifications": "Notify me when @{name} posts", "account.endorse": "Feature on profile", "account.follow": "Follow", "account.followers": "Followers", @@ -170,7 +172,9 @@ "empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.", "empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up", "error.unexpected_crash.explanation": "Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.", + "error.unexpected_crash.explanation_addons": "This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.", "error.unexpected_crash.next_steps": "Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", + "error.unexpected_crash.next_steps_addons": "Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.", "errors.unexpected_crash.copy_stacktrace": "Copy stacktrace to clipboard", "errors.unexpected_crash.report_issue": "Report issue", "follow_request.authorize": "Authorize", @@ -264,6 +268,10 @@ "lists.edit.submit": "Change title", "lists.new.create": "Add list", "lists.new.title_placeholder": "New list title", + "lists.replies_policy.all_replies": "Any followed user", + "lists.replies_policy.list_replies": "Members of the list", + "lists.replies_policy.no_replies": "No one", + "lists.replies_policy.title": "Show replies to:", "lists.search": "Search among people you follow", "lists.subheading": "Your lists", "load_pending": "{count, plural, one {# new item} other {# new items}}", @@ -271,7 +279,9 @@ "media_gallery.toggle_visible": "Hide {number, plural, one {image} other {images}}", "missing_indicator.label": "Not found", "missing_indicator.sublabel": "This resource could not be found", + "mute_modal.duration": "Duration", "mute_modal.hide_notifications": "Hide notifications from this user?", + "mute_modal.indefinite": "Indefinite", "navigation_bar.apps": "Mobile apps", "navigation_bar.blocks": "Blocked users", "navigation_bar.bookmarks": "Bookmarks", @@ -303,6 +313,7 @@ "notification.own_poll": "Your poll has ended", "notification.poll": "A poll you have voted in has ended", "notification.reblog": "{name} boosted your toot", + "notification.status": "{name} just posted", "notifications.clear": "Clear notifications", "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?", "notifications.column_settings.alert": "Desktop notifications", @@ -318,13 +329,22 @@ "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "Show in column", "notifications.column_settings.sound": "Play sound", + "notifications.column_settings.status": "New toots:", "notifications.filter.all": "All", "notifications.filter.boosts": "Boosts", "notifications.filter.favourites": "Favourites", "notifications.filter.follows": "Follows", "notifications.filter.mentions": "Mentions", "notifications.filter.polls": "Poll results", + "notifications.filter.statuses": "Updates from people you follow", "notifications.group": "{count} notifications", + "notifications.mark_as_read": "Mark every notification as read", + "notifications.permission_denied": "Desktop notifications are unavailable due to previously denied browser permissions request", + "notifications.permission_denied_alert": "Desktop notifications can't be enabled, as browser permission has been denied before", + "notifications_permission_banner.enable": "Enable desktop notifications", + "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.", + "notifications_permission_banner.title": "Never miss a thing", + "picture_in_picture.restore": "Put it back", "poll.closed": "Closed", "poll.refresh": "Refresh", "poll.total_people": "{count, plural, one {# person} other {# people}}", @@ -451,6 +471,7 @@ "upload_modal.detect_text": "Detect text from picture", "upload_modal.edit_media": "Edit media", "upload_modal.hint": "Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.", + "upload_modal.preparing_ocr": "Preparing OCR…", "upload_modal.preview_label": "Preview ({ratio})", "upload_progress.label": "Uploading...", "video.close": "Close video", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index af2f5a53f..6ee1c0819 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -272,6 +272,8 @@ "missing_indicator.label": "見つかりません", "missing_indicator.sublabel": "見つかりませんでした", "mute_modal.hide_notifications": "このユーザーからの通知を隠しますか?", + "mute_modal.duration": "ミュートする期間", + "mute_modal.indefinite": "無期限", "navigation_bar.apps": "アプリ", "navigation_bar.blocks": "ブロックしたユーザー", "navigation_bar.bookmarks": "ブックマーク", diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index da4884fd3..bda51f692 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -1,4 +1,5 @@ import * as registerPushNotifications from './actions/push_notifications'; +import { setupBrowserNotifications } from './actions/notifications'; import { default as Mastodon, store } from './containers/mastodon'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -22,6 +23,7 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(<Mastodon {...props} />, mountNode); + store.dispatch(setupBrowserNotifications()); if (process.env.NODE_ENV === 'production') { // avoid offline in dev mode because it's harder to debug require('offline-plugin/runtime').install(); diff --git a/app/javascript/mastodon/reducers/mutes.js b/app/javascript/mastodon/reducers/mutes.js index 4672e5097..a9eb61ff8 100644 --- a/app/javascript/mastodon/reducers/mutes.js +++ b/app/javascript/mastodon/reducers/mutes.js @@ -3,12 +3,14 @@ import Immutable from 'immutable'; import { MUTES_INIT_MODAL, MUTES_TOGGLE_HIDE_NOTIFICATIONS, + MUTES_CHANGE_DURATION, } from '../actions/mutes'; const initialState = Immutable.Map({ new: Immutable.Map({ account: null, notifications: true, + duration: 0, }), }); @@ -21,6 +23,8 @@ export default function mutes(state = initialState, action) { }); case MUTES_TOGGLE_HIDE_NOTIFICATIONS: return state.updateIn(['new', 'notifications'], (old) => !old); + case MUTES_CHANGE_DURATION: + return state.setIn(['new', 'duration'], Number(action.duration)); default: return state; } diff --git a/app/javascript/mastodon/reducers/notifications.js b/app/javascript/mastodon/reducers/notifications.js index b01db806f..1d4874717 100644 --- a/app/javascript/mastodon/reducers/notifications.js +++ b/app/javascript/mastodon/reducers/notifications.js @@ -10,6 +10,8 @@ import { NOTIFICATIONS_MOUNT, NOTIFICATIONS_UNMOUNT, NOTIFICATIONS_MARK_AS_READ, + NOTIFICATIONS_SET_BROWSER_SUPPORT, + NOTIFICATIONS_SET_BROWSER_PERMISSION, } from '../actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -40,6 +42,8 @@ const initialState = ImmutableMap({ readMarkerId: '0', isTabVisible: true, isLoading: false, + browserSupport: false, + browserPermission: 'default', }); const notificationToMap = notification => ImmutableMap({ @@ -151,7 +155,7 @@ const deleteByStatus = (state, statusId) => { const updateMounted = (state) => { state = state.update('mounted', count => count + 1); - if (!shouldCountUnreadNotifications(state)) { + if (!shouldCountUnreadNotifications(state, state.get('mounted') === 1)) { state = state.set('readMarkerId', state.get('lastReadId')); state = clearUnread(state); } @@ -167,14 +171,15 @@ const updateVisibility = (state, visibility) => { return state; }; -const shouldCountUnreadNotifications = (state) => { +const shouldCountUnreadNotifications = (state, ignoreScroll = false) => { const isTabVisible = state.get('isTabVisible'); const isOnTop = state.get('top'); const isMounted = state.get('mounted') > 0; const lastReadId = state.get('lastReadId'); - const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0); + const lastItem = state.get('items').findLast(item => item !== null); + const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (lastItem && compareId(lastItem.get('id'), lastReadId) <= 0); - return !(isTabVisible && isOnTop && isMounted && lastItemReached); + return !(isTabVisible && (ignoreScroll || isOnTop) && isMounted && lastItemReached); }; const recountUnread = (state, last_read_id) => { @@ -241,6 +246,10 @@ export default function notifications(state = initialState, action) { case NOTIFICATIONS_MARK_AS_READ: const lastNotification = state.get('items').find(item => item !== null); return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; + case NOTIFICATIONS_SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case NOTIFICATIONS_SET_BROWSER_PERMISSION: + return state.set('browserPermission', action.value); default: return state; } diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js index 1d050cc63..53949258a 100644 --- a/app/javascript/mastodon/reducers/relationships.js +++ b/app/javascript/mastodon/reducers/relationships.js @@ -45,7 +45,7 @@ const initialState = ImmutableMap(); export default function relationships(state = initialState, action) { switch(action.type) { case ACCOUNT_FOLLOW_REQUEST: - return state.setIn([action.id, action.locked ? 'requested' : 'following'], true); + return state.getIn([action.id, 'following']) ? state : state.setIn([action.id, action.locked ? 'requested' : 'following'], true); case ACCOUNT_FOLLOW_FAIL: return state.setIn([action.id, action.locked ? 'requested' : 'following'], false); case ACCOUNT_UNFOLLOW_REQUEST: diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index efef2ad9a..057fa353a 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -29,12 +29,13 @@ const initialState = ImmutableMap({ notifications: ImmutableMap({ alerts: ImmutableMap({ - follow: true, + follow: false, follow_request: false, - favourite: true, - reblog: true, - mention: true, - poll: true, + favourite: false, + reblog: false, + mention: false, + poll: false, + status: false, }), quickFilter: ImmutableMap({ @@ -50,6 +51,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), sounds: ImmutableMap({ @@ -59,6 +61,7 @@ const initialState = ImmutableMap({ reblog: true, mention: true, poll: true, + status: true, }), }), diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 8165952a7..10aaa2d68 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -53,14 +53,20 @@ import { } from 'mastodon/actions/directory'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +const initialListState = ImmutableMap({ + next: null, + isLoading: false, + items: ImmutableList(), +}); + const initialState = ImmutableMap({ - followers: ImmutableMap(), - following: ImmutableMap(), - reblogged_by: ImmutableMap(), - favourited_by: ImmutableMap(), - follow_requests: ImmutableMap(), - blocks: ImmutableMap(), - mutes: ImmutableMap(), + followers: initialListState, + following: initialListState, + reblogged_by: initialListState, + favourited_by: initialListState, + follow_requests: initialListState, + blocks: initialListState, + mutes: initialListState, }); const normalizeList = (state, path, accounts, next) => { diff --git a/app/javascript/mastodon/utils/config.js b/app/javascript/mastodon/utils/config.js new file mode 100644 index 000000000..932cd0cbf --- /dev/null +++ b/app/javascript/mastodon/utils/config.js @@ -0,0 +1,10 @@ +import ready from '../ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); diff --git a/app/javascript/mastodon/utils/notifications.js b/app/javascript/mastodon/utils/notifications.js new file mode 100644 index 000000000..ab119c2e3 --- /dev/null +++ b/app/javascript/mastodon/utils/notifications.js @@ -0,0 +1,29 @@ +// Handles browser quirks, based on +// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API + +const checkNotificationPromise = () => { + try { + Notification.requestPermission().then(); + } catch(e) { + return false; + } + + return true; +}; + +const handlePermission = (permission, callback) => { + // Whatever the user answers, we make sure Chrome stores the information + if(!('permission' in Notification)) { + Notification.permission = permission; + } + + callback(Notification.permission); +}; + +export const requestNotificationPermission = (callback) => { + if (checkNotificationPromise()) { + Notification.requestPermission().then((permission) => handlePermission(permission, callback)); + } else { + Notification.requestPermission((permission) => handlePermission(permission, callback)); + } +}; diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js index 843cb2c87..892d825ec 100644 --- a/app/javascript/packs/about.js +++ b/app/javascript/packs/about.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c65ebed74..91240aecf 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js index 5d42509c5..05dff8e49 100644 --- a/app/javascript/packs/common.js +++ b/app/javascript/packs/common.js @@ -1 +1,2 @@ +import './public-path'; import 'styles/application.scss'; diff --git a/app/javascript/packs/error.js b/app/javascript/packs/error.js index 685c89065..6376dc2f5 100644 --- a/app/javascript/packs/error.js +++ b/app/javascript/packs/error.js @@ -1,3 +1,4 @@ +import './public-path'; import ready from '../mastodon/ready'; ready(() => { diff --git a/app/javascript/packs/public-path.js b/app/javascript/packs/public-path.js new file mode 100644 index 000000000..f96109f4f --- /dev/null +++ b/app/javascript/packs/public-path.js @@ -0,0 +1,21 @@ +// Dynamically set webpack's loading path depending on a meta header, in order +// to share the same assets regardless of instance configuration. +// See https://webpack.js.org/guides/public-path/#on-the-fly + +function removeOuterSlashes(string) { + return string.replace(/^\/*/, '').replace(/\/*$/, ''); +} + +function formatPublicPath(host = '', path = '') { + let formattedHost = removeOuterSlashes(host); + if (formattedHost && !/^http/i.test(formattedHost)) { + formattedHost = `//${formattedHost}`; + } + const formattedPath = removeOuterSlashes(path); + return `${formattedHost}/${formattedPath}/`; +} + +const cdnHost = document.querySelector('meta[name=cdn-host]'); + +// eslint-disable-next-line camelcase, no-undef, no-unused-vars +__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 5ad25a9b0..3f6700195 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js index 4ef23e1b2..1225d7b52 100644 --- a/app/javascript/packs/share.js +++ b/app/javascript/packs/share.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/skins/glitch/contrast/names.yml b/app/javascript/skins/glitch/contrast/names.yml index f60c4acd0..570e30d3b 100644 --- a/app/javascript/skins/glitch/contrast/names.yml +++ b/app/javascript/skins/glitch/contrast/names.yml @@ -2,3 +2,7 @@ en: skins: glitch: contrast: High contrast +es: + skins: + glitch: + contrast: Alto contraste diff --git a/app/javascript/skins/glitch/mastodon-light/names.yml b/app/javascript/skins/glitch/mastodon-light/names.yml index f15424f2b..891271e6e 100644 --- a/app/javascript/skins/glitch/mastodon-light/names.yml +++ b/app/javascript/skins/glitch/mastodon-light/names.yml @@ -2,4 +2,7 @@ en: skins: glitch: mastodon-light: Mastodon (light) - +es: + skins: + glitch: + mastodon-light: Mastodon (claro) diff --git a/app/javascript/skins/vanilla/contrast/names.yml b/app/javascript/skins/vanilla/contrast/names.yml index d9b930e86..da8974742 100644 --- a/app/javascript/skins/vanilla/contrast/names.yml +++ b/app/javascript/skins/vanilla/contrast/names.yml @@ -2,3 +2,7 @@ en: skins: vanilla: contrast: High contrast +es: + skins: + vanilla: + contrast: Alto contraste diff --git a/app/javascript/skins/vanilla/mastodon-light/names.yml b/app/javascript/skins/vanilla/mastodon-light/names.yml index bb14ad2e0..5e41d1051 100644 --- a/app/javascript/skins/vanilla/mastodon-light/names.yml +++ b/app/javascript/skins/vanilla/mastodon-light/names.yml @@ -2,3 +2,7 @@ en: skins: vanilla: mastodon-light: Mastodon (light) +es: + skins: + glitch: + mastodon-light: Mastodon (claro) diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 0c0eb281a..7f7eee6fe 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -753,3 +753,8 @@ html { .compose-form .compose-form__warning { box-shadow: none; } + +.mute-modal select { + border: 1px solid lighten($ui-base-color, 8%); + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px; +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ff4bb3428..be554a502 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -163,8 +163,7 @@ } .icon-button { - display: inline-flex; - align-items: center; + display: inline-block; padding: 0; color: $action-button-color; border: 0; @@ -173,6 +172,7 @@ cursor: pointer; transition: all 100ms ease-in; transition-property: background-color, color; + text-decoration: none; &:hover, &:active, @@ -247,6 +247,12 @@ } } + &--with-counter { + display: inline-flex; + align-items: center; + width: auto !important; + } + &__counter { display: inline-block; width: 14px; @@ -1152,6 +1158,10 @@ .status__action-bar-button { margin-right: 18px; + + &.icon-button--with-counter { + margin-right: 14px; + } } .status__action-bar-dropdown { @@ -2408,6 +2418,17 @@ a.account__display-name { line-height: 14px; color: $primary-text-color; } + + &__issue-badge { + position: absolute; + left: 11px; + bottom: 1px; + display: block; + background: $error-red; + border-radius: 50%; + width: 0.625rem; + height: 0.625rem; + } } .column-link--transparent .icon-with-badge__badge { @@ -3443,6 +3464,15 @@ a.status-card.compact:hover { cursor: pointer; } +.column-header__issue-btn { + color: $warning-red; + + &:hover { + color: $error-red; + text-decoration: underline; + } +} + .column-header__icon { display: inline-block; margin-right: 5px; @@ -3702,6 +3732,10 @@ a.status-card.compact:hover { margin-bottom: 10px; } +.column-settings__row--with-margin { + margin-bottom: 15px; +} + .column-settings__hashtags { .column-settings__row { margin-bottom: 15px; @@ -5044,6 +5078,22 @@ a.status-card.compact:hover { } } } + + select { + appearance: none; + box-sizing: border-box; + font-size: 14px; + color: $inverted-text-color; + display: inline-block; + width: auto; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") no-repeat right 8px center / auto 16px; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + padding: 6px 10px; + padding-right: 30px; + } } .confirmation-modal__container, @@ -7122,3 +7172,25 @@ noscript { border-color: lighten($ui-base-color, 12%); } } + +.notifications-permission-banner { + padding: 30px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + h2 { + font-size: 16px; + font-weight: 500; + margin-bottom: 15px; + text-align: center; + } + + p { + color: $darker-text-color; + margin-bottom: 15px; + text-align: center; + } +} |