diff options
Diffstat (limited to 'app/javascript')
42 files changed, 716 insertions, 217 deletions
diff --git a/app/javascript/flavours/glitch/components/extended_video_player.js b/app/javascript/flavours/glitch/components/extended_video_player.js index f8bd067e8..9e2f6835a 100644 --- a/app/javascript/flavours/glitch/components/extended_video_player.js +++ b/app/javascript/flavours/glitch/components/extended_video_player.js @@ -11,6 +11,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { time: PropTypes.number, controls: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired, + onClick: PropTypes.func, }; handleLoadedData = () => { @@ -31,6 +32,12 @@ export default class ExtendedVideoPlayer extends React.PureComponent { this.video = c; } + handleClick = e => { + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + render () { const { src, muted, controls, alt } = this.props; @@ -46,6 +53,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { muted={muted} controls={controls} loop={!controls} + onClick={this.handleClick} /> </div> ); diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index bb83374b9..680bf63ab 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; import classnames from 'classnames'; @@ -24,6 +25,7 @@ export default class Card extends React.PureComponent { static propTypes = { card: ImmutablePropTypes.map, maxDescription: PropTypes.number, + onOpenMedia: PropTypes.func.isRequired, }; static defaultProps = { @@ -34,6 +36,27 @@ export default class Card extends React.PureComponent { width: 0, }; + handlePhotoClick = () => { + const { card, onOpenMedia } = this.props; + + onOpenMedia( + Immutable.fromJS([ + { + type: 'image', + url: card.get('url'), + description: card.get('title'), + meta: { + original: { + width: card.get('width'), + height: card.get('height'), + }, + }, + }, + ]), + 0 + ); + }; + renderLink () { const { card, maxDescription } = this.props; @@ -73,9 +96,16 @@ export default class Card extends React.PureComponent { const { card } = this.props; return ( - <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'> - <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} /> - </a> + <img + className='status-card-photo' + onClick={this.handlePhotoClick} + role='button' + tabIndex='0' + src={card.get('url')} + alt={card.get('title')} + width={card.get('width')} + height={card.get('height')} + /> ); } 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 538aa3d28..684cd797b 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -35,9 +35,9 @@ export default class DetailedStatus extends ImmutablePureComponent { e.stopPropagation(); } - // handleOpenVideo = startTime => { - // this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); - // } + handleOpenVideo = startTime => { + this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime); + } render () { const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status; @@ -53,13 +53,15 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + const video = status.getIn(['media_attachments', 0]); media = ( <Video + preview={video.get('preview_url')} + src={video.get('url')} sensitive={status.get('sensitive')} - media={status.getIn(['media_attachments', 0])} letterbox={settings.getIn(['media', 'letterbox'])} fullwidth={settings.getIn(['media', 'fullwidth'])} - onOpenVideo={this.props.onOpenVideo} + onOpenVideo={this.handleOpenVideo} autoplay /> ); @@ -75,7 +77,7 @@ export default class DetailedStatus extends ImmutablePureComponent { ); mediaIcon = 'picture-o'; } - } else media = <CardContainer statusId={status.get('id')} />; + } else media = <CardContainer onOpenMedia={this.props.onOpenMedia} statusId={status.get('id')} />; if (status.get('application')) { applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>; diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js index aad594380..c7360a726 100644 --- a/app/javascript/flavours/glitch/features/ui/components/image_loader.js +++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js @@ -1,15 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import ZoomableImage from './zoomable_image'; export default class ImageLoader extends React.PureComponent { static propTypes = { alt: PropTypes.string, src: PropTypes.string.isRequired, - previewSrc: PropTypes.string.isRequired, + previewSrc: PropTypes.string, width: PropTypes.number, height: PropTypes.number, + onClick: PropTypes.func, } static defaultProps = { @@ -24,6 +26,7 @@ export default class ImageLoader extends React.PureComponent { } removers = []; + canvas = null; get canvasContext() { if (!this.canvas) { @@ -43,11 +46,15 @@ export default class ImageLoader extends React.PureComponent { } } + componentWillUnmount () { + this.removeEventListeners(); + } + loadImage (props) { this.removeEventListeners(); this.setState({ loading: true, error: false }); Promise.all([ - this.loadPreviewCanvas(props), + props.previewSrc && this.loadPreviewCanvas(props), this.hasSize() && this.loadOriginalImage(props), ].filter(Boolean)) .then(() => { @@ -118,7 +125,7 @@ export default class ImageLoader extends React.PureComponent { } render () { - const { alt, src, width, height } = this.props; + const { alt, src, width, height, onClick } = this.props; const { loading } = this.state; const className = classNames('image-loader', { @@ -128,22 +135,19 @@ export default class ImageLoader extends React.PureComponent { return ( <div className={className}> - <canvas - className='image-loader__preview-canvas' - width={width} - height={height} - ref={this.setCanvasRef} - style={{ opacity: loading ? 1 : 0 }} - /> - - {!loading && ( - <img - alt={alt} - className='image-loader__img' - src={src} + {loading ? ( + <canvas + className='image-loader__preview-canvas' + ref={this.setCanvasRef} width={width} height={height} /> + ) : ( + <ZoomableImage + alt={alt} + src={src} + onClick={onClick} + /> )} </div> ); 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 e56147c5b..6ab6770ed 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -3,6 +3,7 @@ import ReactSwipeableViews from 'react-swipeable-views'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; +import classNames from 'classnames'; import { defineMessages, injectIntl } from 'react-intl'; import IconButton from 'flavours/glitch/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -26,6 +27,7 @@ export default class MediaModal extends ImmutablePureComponent { state = { index: null, + navigationHidden: false, }; handleSwipe = (index) => { @@ -68,14 +70,21 @@ export default class MediaModal extends ImmutablePureComponent { return this.state.index !== null ? this.state.index : this.props.index; } + toggleNavigation = () => { + this.setState(prevState => ({ + navigationHidden: !prevState.navigationHidden, + })); + }; + render () { const { media, intl, onClose } = this.props; + const { navigationHidden } = this.state; const index = this.getIndex(); let pagination = []; - const leftNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; - const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; + const leftNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>; + const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>; if (media.size > 1) { pagination = media.map((item, i) => { @@ -92,33 +101,77 @@ export default class MediaModal extends ImmutablePureComponent { const height = image.getIn(['meta', 'original', 'height']) || null; if (image.get('type') === 'image') { - return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />; + return ( + <ImageLoader + previewSrc={image.get('preview_url')} + src={image.get('url')} + width={width} + height={height} + alt={image.get('description')} + key={image.get('url')} + onClick={this.toggleNavigation} + /> + ); } else if (image.get('type') === 'gifv') { - return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />; + return ( + <ExtendedVideoPlayer + src={image.get('url')} + muted + controls={false} + width={width} + height={height} + key={image.get('preview_url')} + alt={image.get('description')} + onClick={this.toggleNavigation} + /> + ); } return null; }).toArray(); + // you can't use 100vh, because the viewport height is taller + // than the visible part of the document in some mobile + // browsers when it's address bar is visible. + // https://developers.google.com/web/updates/2016/12/url-bar-resizing + const swipeableViewsStyle = { + width: '100%', + height: '100%', + }; + const containerStyle = { alignItems: 'center', // center vertically }; + const navigationClassName = classNames('media-modal__navigation', { + 'media-modal__navigation--hidden': navigationHidden, + }); + return ( <div className='modal-root__modal media-modal'> - {leftNav} - - <div className='media-modal__content'> - <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> - <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}> + <div + className='media-modal__closer' + role='presentation' + onClick={onClose} + > + <ReactSwipeableViews + style={swipeableViewsStyle} + containerStyle={containerStyle} + onChangeIndex={this.handleSwipe} + onSwitching={this.handleSwitching} + index={index} + > {content} </ReactSwipeableViews> </div> - <ul className='media-modal__pagination'> - {pagination} - </ul> - - {rightNav} + <div className={navigationClassName}> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} /> + {leftNav} + {rightNav} + <ul className='media-modal__pagination'> + {pagination} + </ul> + </div> </div> ); } 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 4412fd0f7..e0cb7fc09 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -16,7 +16,7 @@ export default class VideoModal extends ImmutablePureComponent { const { media, time, onClose } = this.props; return ( - <div className='modal-root__modal media-modal'> + <div className='modal-root__modal video-modal'> <div> <Video preview={media.get('preview_url')} diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js new file mode 100644 index 000000000..0a0a4d41a --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const MIN_SCALE = 1; +const MAX_SCALE = 4; + +const getMidpoint = (p1, p2) => ({ + x: (p1.clientX + p2.clientX) / 2, + y: (p1.clientY + p2.clientY) / 2, +}); + +const getDistance = (p1, p2) => + Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2)); + +const clamp = (min, max, value) => Math.min(max, Math.max(min, value)); + +export default class ZoomableImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, + onClick: PropTypes.func, + } + + static defaultProps = { + alt: '', + width: null, + height: null, + }; + + state = { + scale: MIN_SCALE, + } + + removers = []; + container = null; + image = null; + lastTouchEndTime = 0; + lastDistance = 0; + + componentDidMount () { + let handler = this.handleTouchStart; + this.container.addEventListener('touchstart', handler); + this.removers.push(() => this.container.removeEventListener('touchstart', handler)); + handler = this.handleTouchMove; + // on Chrome 56+, touch event listeners will default to passive + // https://www.chromestatus.com/features/5093566007214080 + this.container.addEventListener('touchmove', handler, { passive: false }); + this.removers.push(() => this.container.removeEventListener('touchend', handler)); + } + + componentWillUnmount () { + this.removeEventListeners(); + } + + removeEventListeners () { + this.removers.forEach(listeners => listeners()); + this.removers = []; + } + + handleTouchStart = e => { + if (e.touches.length !== 2) return; + + this.lastDistance = getDistance(...e.touches); + } + + handleTouchMove = e => { + const { scrollTop, scrollHeight, clientHeight } = this.container; + if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) { + // prevent propagating event to MediaModal + e.stopPropagation(); + return; + } + if (e.touches.length !== 2) return; + + e.preventDefault(); + e.stopPropagation(); + + const distance = getDistance(...e.touches); + const midpoint = getMidpoint(...e.touches); + const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance); + + this.zoom(scale, midpoint); + + this.lastMidpoint = midpoint; + this.lastDistance = distance; + } + + zoom(nextScale, midpoint) { + const { scale } = this.state; + const { scrollLeft, scrollTop } = this.container; + + // math memo: + // x = (scrollLeft + midpoint.x) / scrollWidth + // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth + // scrollWidth = clientWidth * scale + // scrollWidth' = clientWidth * nextScale + // Solve x = x' for nextScrollLeft + const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x; + const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y; + + this.setState({ scale: nextScale }, () => { + this.container.scrollLeft = nextScrollLeft; + this.container.scrollTop = nextScrollTop; + }); + } + + handleClick = e => { + // don't propagate event to MediaModal + e.stopPropagation(); + const handler = this.props.onClick; + if (handler) handler(); + } + + setContainerRef = c => { + this.container = c; + } + + setImageRef = c => { + this.image = c; + } + + render () { + const { alt, src } = this.props; + const { scale } = this.state; + const overflow = scale === 1 ? 'hidden' : 'scroll'; + + return ( + <div + className='zoomable-image' + ref={this.setContainerRef} + style={{ overflow }} + > + <img + role='presentation' + ref={this.setImageRef} + alt={alt} + src={src} + style={{ + transform: `scale(${scale})`, + transformOrigin: '0 0', + }} + onClick={this.handleClick} + /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index f9245e134..3146a343d 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -135,6 +135,11 @@ border: 0; background: transparent; border-bottom: 1px solid $ui-base-color; + + &.section-break { + margin: 30px 0; + border-bottom: 2px solid $ui-base-lighter-color; + } } .muted-hint { @@ -336,6 +341,36 @@ } } +.report-note__comment { + margin-bottom: 20px; +} + +.report-note__form { + margin-bottom: 20px; + + .report-note__textarea { + box-sizing: border-box; + border: 0; + padding: 7px 4px; + margin-bottom: 10px; + font-size: 16px; + color: $ui-base-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + } + + .report-note__buttons { + text-align: right; + } + + .report-note__button { + margin: 0 0 5px 5px; + } +} + .batch-form-box { display: flex; flex-wrap: wrap; diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index aa33c9333..afb54056c 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -353,35 +353,42 @@ .image-loader { position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; - &.image-loader--loading { - .image-loader__preview-canvas { - filter: blur(2px); - } + .image-loader__preview-canvas { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + background: url('~images/void.png') repeat; + object-fit: contain; } - .image-loader__img { - position: absolute; - top: 0; - left: 0; - right: 0; - max-width: 100%; - max-height: 100%; - background-image: none; + &.image-loader--loading .image-loader__preview-canvas { + filter: blur(2px); } - &.image-loader--amorphous { - position: static; + &.image-loader--amorphous .image-loader__preview-canvas { + display: none; + } +} - .image-loader__preview-canvas { - display: none; - } +.zoomable-image { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; - .image-loader__img { - position: static; - width: auto; - height: auto; - } + img { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + width: auto; + height: auto; + object-fit: contain; } } diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss index d7407cdaf..03e7aba67 100644 --- a/app/javascript/flavours/glitch/styles/components/media.scss +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -157,43 +157,85 @@ position: absolute; } -.media-modal { - max-width: 80vw; - max-height: 80vh; +.video-modal { + max-width: 100vw; + max-height: 100vh; position: relative; - .extended-video-player, - img, - canvas, - video { - max-width: 80vw; - max-height: 80vh; - width: auto; - height: auto; - margin: auto; - } - - .extended-video-player, - video { + .extended-video-player { + width: 100%; + height: 100%; display: flex; - width: 80vw; - height: 80vh; + align-items: center; + justify-content: center; + + video { + max-width: $media-modal-media-max-width; + max-height: $media-modal-media-max-height; + } } +} - img, - canvas { - display: block; - background: url('~images/void.png') repeat; - object-fit: contain; +.media-modal { + width: 100%; + height: 100%; + position: relative; +} + +.media-modal__closer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.media-modal__navigation { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + transition: opacity 0.3s linear; + will-change: opacity; + + * { + pointer-events: auto; } - .react-swipeable-view-container { - max-width: 80vw; + &.media-modal__navigation--hidden { + opacity: 0; + + * { + pointer-events: none; + } } } -.media-modal__content { - background: $base-overlay-background; +.media-modal__nav { + background: rgba($base-overlay-background, 0.5); + box-sizing: border-box; + border: 0; + color: $primary-text-color; + cursor: pointer; + display: flex; + align-items: center; + font-size: 24px; + height: 20vmax; + margin: auto 0; + padding: 30px 15px; + position: absolute; + top: 0; + bottom: 0; +} + +.media-modal__nav--left { + left: 0; +} + +.media-modal__nav--right { + right: 0; } .media-modal__pagination { @@ -201,7 +243,8 @@ text-align: center; position: absolute; left: 0; - bottom: -40px; + bottom: 20px; + pointer-events: none; } .media-modal__page-dot { @@ -225,8 +268,8 @@ .media-modal__close { position: absolute; - right: 4px; - top: 4px; + right: 8px; + top: 8px; z-index: 100; } @@ -244,8 +287,8 @@ @include fullwidth-gallery; video { - height: 100%; - width: 100%; + max-width: 100vw; + max-height: 80vh; z-index: 1; object-fit: cover; position: relative; diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index d424b1eda..4f0d6e1bc 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -2,29 +2,6 @@ background: lighten($ui-base-color, 8%); } -.modal-container__nav { - align-items: center; - background: rgba($base-overlay-background, 0.5); - box-sizing: border-box; - border: 0; - color: $primary-text-color; - cursor: pointer; - display: flex; - font-size: 24px; - height: 100%; - padding: 30px 15px; - position: absolute; - top: 0; -} - -.modal-container__nav--left { - left: -61px; -} - -.modal-container__nav--right { - right: -61px; -} - .modal-root { transition: opacity 0.3s linear; will-change: opacity; diff --git a/app/javascript/flavours/glitch/styles/variables.scss b/app/javascript/flavours/glitch/styles/variables.scss index e8e2bc9e3..e3ba725c4 100644 --- a/app/javascript/flavours/glitch/styles/variables.scss +++ b/app/javascript/flavours/glitch/styles/variables.scss @@ -31,6 +31,11 @@ $ui-highlight-color: $classic-highlight-color !default; // Vibrant // Language codes that uses CJK fonts $cjk-langs: ja, ko, zh-CN, zh-HK, zh-TW; +// Variables for components +$media-modal-media-max-width: 100%; +// put margins on top and bottom of image to avoid the screen covered by image. +$media-modal-media-max-height: 80%; + // Avatar border size (8% default, 100% for rounded avatars) $ui-avatar-border-size: 8%; diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 28ae56763..c9e4afcfc 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -1,5 +1,5 @@ import api, { getLinks } from '../api'; -import asyncDB from '../storage/db'; +import openDB from '../storage/db'; import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer'; export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST'; @@ -94,12 +94,15 @@ export function fetchAccount(id) { dispatch(fetchAccountRequest(id)); - asyncDB.then(db => getFromDB( + openDB().then(db => getFromDB( dispatch, getState, db.transaction('accounts', 'read').objectStore('accounts').index('id'), id - )).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => { + ).then(() => db.close(), error => { + db.close(); + throw error; + })).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => { dispatch(importFetchedAccount(response.data)); })).then(() => { dispatch(fetchAccountSuccess()); diff --git a/app/javascript/mastodon/actions/custom_emojis.js b/app/javascript/mastodon/actions/custom_emojis.js new file mode 100644 index 000000000..aa37bc423 --- /dev/null +++ b/app/javascript/mastodon/actions/custom_emojis.js @@ -0,0 +1,37 @@ +import api from '../api'; + +export const CUSTOM_EMOJIS_FETCH_REQUEST = 'CUSTOM_EMOJIS_FETCH_REQUEST'; +export const CUSTOM_EMOJIS_FETCH_SUCCESS = 'CUSTOM_EMOJIS_FETCH_SUCCESS'; +export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL'; + +export function fetchCustomEmojis() { + return (dispatch, getState) => { + dispatch(fetchCustomEmojisRequest()); + + api(getState).get('/api/v1/custom_emojis').then(response => { + dispatch(fetchCustomEmojisSuccess(response.data)); + }).catch(error => { + dispatch(fetchCustomEmojisFail(error)); + }); + }; +}; + +export function fetchCustomEmojisRequest() { + return { + type: CUSTOM_EMOJIS_FETCH_REQUEST, + }; +}; + +export function fetchCustomEmojisSuccess(custom_emojis) { + return { + type: CUSTOM_EMOJIS_FETCH_SUCCESS, + custom_emojis, + }; +}; + +export function fetchCustomEmojisFail(error) { + return { + type: CUSTOM_EMOJIS_FETCH_FAIL, + error, + }; +}; diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index e671d417c..5b18cbc1d 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -1,3 +1,4 @@ +import { autoPlayGif } from '../../initial_state'; import { putAccounts, putStatuses } from '../../storage/modifier'; import { normalizeAccount, normalizeStatus } from './normalizer'; @@ -44,7 +45,7 @@ export function importFetchedAccounts(accounts) { } accounts.forEach(processAccount); - putAccounts(normalAccounts); + putAccounts(normalAccounts, !autoPlayGif); return importAccounts(normalAccounts); } diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index d28aef880..849cb4f5a 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -1,5 +1,5 @@ import api from '../api'; -import asyncDB from '../storage/db'; +import openDB from '../storage/db'; import { evictStatus } from '../storage/modifier'; import { deleteFromTimelines } from './timelines'; @@ -92,12 +92,17 @@ export function fetchStatus(id) { dispatch(fetchStatusRequest(id, skipLoading)); - asyncDB.then(db => { + openDB().then(db => { const transaction = db.transaction(['accounts', 'statuses'], 'read'); const accountIndex = transaction.objectStore('accounts').index('id'); const index = transaction.objectStore('statuses').index('id'); - return getFromDB(dispatch, getState, accountIndex, index, id); + return getFromDB(dispatch, getState, accountIndex, index, id).then(() => { + db.close(); + }, error => { + db.close(); + throw error; + }); }).then(() => { dispatch(fetchStatusSuccess(skipLoading)); }, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => { diff --git a/app/javascript/mastodon/components/load_gap.js b/app/javascript/mastodon/components/load_gap.js new file mode 100644 index 000000000..012303ae1 --- /dev/null +++ b/app/javascript/mastodon/components/load_gap.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { injectIntl, defineMessages } from 'react-intl'; + +const messages = defineMessages({ + load_more: { id: 'status.load_more', defaultMessage: 'Load more' }, +}); + +@injectIntl +export default class LoadGap extends React.PureComponent { + + static propTypes = { + disabled: PropTypes.bool, + maxId: PropTypes.string, + onClick: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleClick = () => { + this.props.onClick(this.props.maxId); + } + + render () { + const { disabled, intl } = this.props; + + return ( + <button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}> + <i className='fa fa-ellipsis-h' /> + </button> + ); + } + +} diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index a918a94f8..6129b3f1e 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -31,6 +31,8 @@ export default class Status extends ImmutablePureComponent { onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onDirect: PropTypes.func, + onMention: PropTypes.func, onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index e036dc1da..10f34b0c7 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -9,6 +9,7 @@ import { me } from '../initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, @@ -41,6 +42,7 @@ export default class StatusActionBar extends ImmutablePureComponent { onFavourite: PropTypes.func, onReblog: PropTypes.func, onDelete: PropTypes.func, + onDirect: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, onBlock: PropTypes.func, @@ -92,6 +94,10 @@ export default class StatusActionBar extends ImmutablePureComponent { this.props.onMention(this.props.status.get('account'), this.context.router.history); } + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.context.router.history); + } + handleMuteClick = () => { this.props.onMute(this.props.status.get('account')); } @@ -149,6 +155,7 @@ export default class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 8c2673f30..c98d4564e 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -4,28 +4,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import StatusContainer from '../containers/status_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import LoadMore from './load_more'; +import LoadGap from './load_gap'; import ScrollableList from './scrollable_list'; import { FormattedMessage } from 'react-intl'; -class LoadGap extends ImmutablePureComponent { - - static propTypes = { - disabled: PropTypes.bool, - maxId: PropTypes.string, - onClick: PropTypes.func.isRequired, - }; - - handleClick = () => { - this.props.onClick(this.props.maxId); - } - - render () { - return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />; - } - -} - export default class StatusList extends ImmutablePureComponent { static propTypes = { diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index d1710445b..b29898d3b 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -6,6 +6,7 @@ import { showOnboardingOnce } from '../actions/onboarding'; import { BrowserRouter, Route } from 'react-router-dom'; import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; +import { fetchCustomEmojis } from '../actions/custom_emojis'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; @@ -19,6 +20,9 @@ export const store = configureStore(); const hydrateAction = hydrateStore(initialState); store.dispatch(hydrateAction); +// load custom emojis +store.dispatch(fetchCustomEmojis()); + export default class Mastodon extends React.PureComponent { static propTypes = { diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 4579bd132..f22509edf 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -5,6 +5,7 @@ import { makeGetStatus } from '../selectors'; import { replyCompose, mentionCompose, + directCompose, } from '../actions/compose'; import { reblog, @@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onDirect (account, router) { + dispatch(directCompose(account, router)); + }, + onMention (account, router) { dispatch(mentionCompose(account, router)); }, diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js index e6a535a5d..5ec937a39 100644 --- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js @@ -38,7 +38,8 @@ const getFrequentlyUsedEmojis = createSelector([ .toArray(); if (emojis.length < DEFAULTS.length) { - emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length)); + let uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji)); + emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length)); } return emojis; diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index d5cd854db..67f0e7981 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -24,9 +24,9 @@ const messages = defineMessages({ logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, }); -const mapStateToProps = state => ({ +const mapStateToProps = (state, ownProps) => ({ columns: state.getIn(['settings', 'columns']), - showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), + showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage, }); @connect(mapStateToProps) @@ -38,6 +38,7 @@ export default class Compose extends React.PureComponent { columns: ImmutablePropTypes.list.isRequired, multiColumn: PropTypes.bool, showSearch: PropTypes.bool, + isSearchPage: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -58,7 +59,7 @@ export default class Compose extends React.PureComponent { } render () { - const { multiColumn, showSearch, intl } = this.props; + const { multiColumn, showSearch, isSearchPage, intl } = this.props; let header = ''; @@ -89,7 +90,7 @@ export default class Compose extends React.PureComponent { <div className='drawer'> {header} - <SearchContainer /> + {(multiColumn || isSearchPage) && <SearchContainer /> } <div className='drawer__pager'> <div className='drawer__inner' onFocus={this.onFocus}> @@ -102,7 +103,7 @@ export default class Compose extends React.PureComponent { )} </div> - <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 0 : -100, { stiffness: 210, damping: 20 }) }}> {({ x }) => ( <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> <SearchResultsContainer /> diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 9a6fb45c8..94a46b833 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -13,7 +13,7 @@ import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from '../../components/scrollable_list'; -import LoadMore from '../../components/load_more'; +import LoadGap from '../../components/load_gap'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -24,24 +24,6 @@ const getNotifications = createSelector([ state => state.getIn(['notifications', 'items']), ], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')))); -class LoadGap extends React.PureComponent { - - static propTypes = { - disabled: PropTypes.bool, - maxId: PropTypes.string, - onClick: PropTypes.func.isRequired, - }; - - handleClick = () => { - this.props.onClick(this.props.maxId); - } - - render () { - return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />; - } - -} - const mapStateToProps = state => ({ notifications: getNotifications(state), isLoading: state.getIn(['notifications', 'isLoading'], true), diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index 13cc10c9c..4aa6b08f2 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -8,6 +8,7 @@ import { me } from '../../../initial_state'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, + direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, @@ -37,6 +38,7 @@ export default class ActionBar extends React.PureComponent { onReblog: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, + onDirect: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onMute: PropTypes.func, onMuteConversation: PropTypes.func, @@ -63,6 +65,10 @@ export default class ActionBar extends React.PureComponent { this.props.onDelete(this.props.status); } + handleDirectClick = () => { + this.props.onDirect(this.props.status.get('account'), this.context.router.history); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -108,6 +114,7 @@ export default class ActionBar extends React.PureComponent { if (publicStatus) { menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); + menu.push(null); } if (me === status.getIn(['account', 'id'])) { @@ -121,6 +128,7 @@ export default class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); } else { menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick }); + menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick }); menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick }); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 2f482b292..55eff0823 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -19,6 +19,7 @@ import { import { replyCompose, mentionCompose, + directCompose, } from '../../actions/compose'; import { blockAccount } from '../../actions/accounts'; import { @@ -148,6 +149,10 @@ export default class Status extends ImmutablePureComponent { } } + handleDirectClick = (account, router) => { + this.props.dispatch(directCompose(account, router)); + } + handleMentionClick = (account, router) => { this.props.dispatch(mentionCompose(account, router)); } @@ -379,6 +384,7 @@ export default class Status extends ImmutablePureComponent { onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} + onDirect={this.handleDirectClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} onMuteConversation={this.handleConversationMuteClick} diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js index dba3be98b..ed6de6f39 100644 --- a/app/javascript/mastodon/features/ui/components/tabs_bar.js +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -8,6 +8,7 @@ import { isUserTouching } from '../../../is_mobile'; export const links = [ <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>, <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>, + <NavLink className='tabs-bar__link primary' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><i className='fa fa-fw fa-search' /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>, <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>, <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>, diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 8894eb4e6..8b905fa1d 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -146,6 +146,8 @@ class SwitchingColumnsArea extends React.PureComponent { <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} /> <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} /> + <WrappedRoute path='/search' component={Compose} content={children} componentParams={{ isSearchPage: true }} /> + <WrappedRoute path='/statuses/new' component={Compose} content={children} /> <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} /> <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} /> diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index dd249adf1..6f81db13e 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -198,6 +198,10 @@ "id": "status.delete" }, { + "defaultMessage": "Direct message @{name}", + "id": "status.direct" + }, + { "defaultMessage": "Mention @{name}", "id": "status.mention" }, @@ -1371,6 +1375,10 @@ "id": "status.delete" }, { + "defaultMessage": "Direct message @{name}", + "id": "status.direct" + }, + { "defaultMessage": "Mention @{name}", "id": "status.mention" }, @@ -1731,6 +1739,10 @@ "id": "tabs_bar.notifications" }, { + "defaultMessage": "Search", + "id": "tabs_bar.search" + }, + { "defaultMessage": "Local", "id": "tabs_bar.local_timeline" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index afc0fce3d..4802ddfd1 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -247,6 +247,7 @@ "status.block": "Block @{name}", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Delete", + "status.direct": "Direct message @{name}", "status.embed": "Embed", "status.favourite": "Favourite", "status.load_more": "Load more", @@ -276,6 +277,7 @@ "tabs_bar.home": "Home", "tabs_bar.local_timeline": "Local", "tabs_bar.notifications": "Notifications", + "tabs_bar.search": "Search", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add media", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index c0877262f..82b7070b8 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -276,6 +276,7 @@ "tabs_bar.home": "Strona główna", "tabs_bar.local_timeline": "Lokalne", "tabs_bar.notifications": "Powiadomienia", + "tabs_bar.search": "Szukaj", "ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.", "upload_area.title": "Przeciągnij i upuść aby wysłać", "upload_button.label": "Dodaj zawartość multimedialną", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index b056ec8bd..4cd2e0643 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -189,7 +189,7 @@ "onboarding.page_one.federation": "Mastodon é uma rede de servidores independentes que se juntam para fazer uma grande rede social. Nós chamamos estes servidores de instâncias.", "onboarding.page_one.full_handle": "Seu nome de usuário completo", "onboarding.page_one.handle_hint": "Isso é o que você diz aos seus amigos para que eles possam te mandar mensagens ou te seguir a partir de outra instância.", - "onboarding.page_one.welcome": "Seja bem-vindo(a) ao Mastodon!", + "onboarding.page_one.welcome": "Boas-vindas ao Mastodon!", "onboarding.page_six.admin": "O administrador de sua instância é {admin}.", "onboarding.page_six.almost_done": "Quase acabando...", "onboarding.page_six.appetoot": "Bom Apetoot!", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index 65983000c..7a404eaba 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -189,7 +189,7 @@ "onboarding.page_one.federation": "Mastodon é uma rede de servidores independentes ligados entre si para fazer uma grande rede social. Nós chamamos instâncias a estes servidores.", "onboarding.page_one.full_handle": "O teu nome de utilizador completo", "onboarding.page_one.handle_hint": "Isto é o que dizes aos teus amigos para pesquisar.", - "onboarding.page_one.welcome": "Bem-vindo(a) ao Mastodon!", + "onboarding.page_one.welcome": "Boas-vindas ao Mastodon!", "onboarding.page_six.admin": "O administrador da tua instância é {admin}.", "onboarding.page_six.almost_done": "Quase pronto...", "onboarding.page_six.appetoot": "Bon Appetoot!", diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 1f4177585..87049ea79 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -259,16 +259,18 @@ export default function compose(state = initialState, action) { case COMPOSE_UPLOAD_PROGRESS: return state.set('progress', Math.round((action.loaded / action.total) * 100)); case COMPOSE_MENTION: - return state - .update('text', text => `${text}@${action.account.get('acct')} `) - .set('focusDate', new Date()) - .set('idempotencyKey', uuid()); + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); case COMPOSE_DIRECT: - return state - .update('text', text => `@${action.account.get('acct')} `) - .set('privacy', 'direct') - .set('focusDate', new Date()) - .set('idempotencyKey', uuid()); + return state.withMutations(map => { + map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); + map.set('privacy', 'direct'); + map.set('focusDate', new Date()); + map.set('idempotencyKey', uuid()); + }); case COMPOSE_SUGGESTIONS_CLEAR: return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js index 307bcc7dc..d2c801ade 100644 --- a/app/javascript/mastodon/reducers/custom_emojis.js +++ b/app/javascript/mastodon/reducers/custom_emojis.js @@ -1,16 +1,15 @@ -import { List as ImmutableList } from 'immutable'; -import { STORE_HYDRATE } from '../actions/store'; +import { List as ImmutableList, fromJS as ConvertToImmutable } from 'immutable'; +import { CUSTOM_EMOJIS_FETCH_SUCCESS } from '../actions/custom_emojis'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { buildCustomEmojis } from '../features/emoji/emoji'; -const initialState = ImmutableList(); +const initialState = ImmutableList([]); export default function custom_emojis(state = initialState, action) { - switch(action.type) { - case STORE_HYDRATE: - emojiSearch('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); - return action.state.get('custom_emojis'); - default: - return state; + if(action.type === CUSTOM_EMOJIS_FETCH_SUCCESS) { + state = ConvertToImmutable(action.custom_emojis); + emojiSearch('', { custom: buildCustomEmojis(state) }); } + + return state; }; diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js index 160c3fbf2..ba54ae996 100644 --- a/app/javascript/mastodon/service_worker/entry.js +++ b/app/javascript/mastodon/service_worker/entry.js @@ -1,3 +1,4 @@ +import { freeStorage } from '../storage/modifier'; import './web_push_notifications'; function openSystemCache() { @@ -42,8 +43,10 @@ self.addEventListener('fetch', function(event) { event.respondWith(asyncResponse.then(async response => { if (response.ok || response.type === 'opaqueredirect') { - const cache = await asyncCache; - await cache.delete('/'); + await Promise.all([ + asyncCache.then(cache => cache.delete('/')), + indexedDB.deleteDatabase('mastodon'), + ]); } return response; @@ -56,7 +59,11 @@ self.addEventListener('fetch', function(event) { const fetched = await fetch(event.request); if (fetched.ok) { - await cache.put(event.request.url, fetched.clone()); + try { + await cache.put(event.request.url, fetched.clone()); + } finally { + freeStorage(); + } } return fetched; diff --git a/app/javascript/mastodon/storage/db.js b/app/javascript/mastodon/storage/db.js index e08fc3f3d..377a792a7 100644 --- a/app/javascript/mastodon/storage/db.js +++ b/app/javascript/mastodon/storage/db.js @@ -1,15 +1,14 @@ -import { me } from '../initial_state'; - -export default new Promise((resolve, reject) => { +export default () => new Promise((resolve, reject) => { + // ServiceWorker is required to synchronize the login state. // Microsoft Edge 17 does not support getAll according to: // Catalog of standard and vendor APIs across browsers - Microsoft Edge Development // https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb - if (!me || !('getAll' in IDBObjectStore.prototype)) { + if (!('caches' in self && 'getAll' in IDBObjectStore.prototype)) { reject(); return; } - const request = indexedDB.open('mastodon:' + me); + const request = indexedDB.open('mastodon'); request.onerror = reject; request.onsuccess = ({ target }) => resolve(target.result); diff --git a/app/javascript/mastodon/storage/modifier.js b/app/javascript/mastodon/storage/modifier.js index 4773d07a9..c2ed6f807 100644 --- a/app/javascript/mastodon/storage/modifier.js +++ b/app/javascript/mastodon/storage/modifier.js @@ -1,13 +1,14 @@ -import asyncDB from './db'; -import { autoPlayGif } from '../initial_state'; +import openDB from './db'; const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static']; -const avatarKey = autoPlayGif ? 'avatar' : 'avatar_static'; -const limit = 1024; +const storageMargin = 8388608; +const storeLimit = 1024; -// ServiceWorker and Cache API is not available on iOS 11 -// https://webkit.org/status/#specification-service-workers -const asyncCache = window.caches ? caches.open('mastodon-system') : Promise.reject(); +function openCache() { + // ServiceWorker and Cache API is not available on iOS 11 + // https://webkit.org/status/#specification-service-workers + return self.caches ? caches.open('mastodon-system') : Promise.reject(); +} function printErrorIfAvailable(error) { if (error) { @@ -16,7 +17,7 @@ function printErrorIfAvailable(error) { } function put(name, objects, onupdate, oncreate) { - return asyncDB.then(db => new Promise((resolve, reject) => { + return openDB().then(db => (new Promise((resolve, reject) => { const putTransaction = db.transaction(name, 'readwrite'); const putStore = putTransaction.objectStore(name); const putIndex = putStore.index('id'); @@ -53,7 +54,7 @@ function put(name, objects, onupdate, oncreate) { const count = readStore.count(); count.onsuccess = () => { - const excess = count.result - limit; + const excess = count.result - storeLimit; if (excess > 0) { const retrieval = readStore.getAll(null, excess); @@ -69,11 +70,17 @@ function put(name, objects, onupdate, oncreate) { }; putTransaction.onerror = reject; + })).then(resolved => { + db.close(); + return resolved; + }, error => { + db.close(); + throw error; })); } function evictAccountsByRecords(records) { - asyncDB.then(db => { + return openDB().then(db => { const transaction = db.transaction(['accounts', 'statuses'], 'readwrite'); const accounts = transaction.objectStore('accounts'); const accountsIdIndex = accounts.index('id'); @@ -83,7 +90,7 @@ function evictAccountsByRecords(records) { function evict(toEvict) { toEvict.forEach(record => { - asyncCache + openCache() .then(cache => accountAssetKeys.forEach(key => cache.delete(records[key]))) .catch(printErrorIfAvailable); @@ -98,6 +105,8 @@ function evictAccountsByRecords(records) { } evict(records); + + db.close(); }).catch(printErrorIfAvailable); } @@ -106,8 +115,9 @@ export function evictStatus(id) { } export function evictStatuses(ids) { - asyncDB.then(db => { - const store = db.transaction('statuses', 'readwrite').objectStore('statuses'); + return openDB().then(db => { + const transaction = db.transaction('statuses', 'readwrite'); + const store = transaction.objectStore('statuses'); const idIndex = store.index('id'); const reblogIndex = store.index('reblog'); @@ -118,14 +128,17 @@ export function evictStatuses(ids) { idIndex.getKey(id).onsuccess = ({ target }) => target.result && store.delete(target.result); }); + + db.close(); }).catch(printErrorIfAvailable); } function evictStatusesByRecords(records) { - evictStatuses(records.map(({ id }) => id)); + return evictStatuses(records.map(({ id }) => id)); } -export function putAccounts(records) { +export function putAccounts(records, avatarStatic) { + const avatarKey = avatarStatic ? 'avatar_static' : 'avatar'; const newURLs = []; put('accounts', records, (newRecord, oldKey, store, oncomplete) => { @@ -135,7 +148,7 @@ export function putAccounts(records) { const oldURL = target.result[key]; if (newURL !== oldURL) { - asyncCache + openCache() .then(cache => cache.delete(oldURL)) .catch(printErrorIfAvailable); } @@ -153,11 +166,12 @@ export function putAccounts(records) { }, (newRecord, oncomplete) => { newURLs.push(newRecord[avatarKey]); oncomplete(); - }).then(records => { - evictAccountsByRecords(records); - asyncCache - .then(cache => cache.addAll(newURLs)) - .catch(printErrorIfAvailable); + }).then(records => Promise.all([ + evictAccountsByRecords(records), + openCache().then(cache => cache.addAll(newURLs)), + ])).then(freeStorage, error => { + freeStorage(); + throw error; }).catch(printErrorIfAvailable); } @@ -166,3 +180,27 @@ export function putStatuses(records) { .then(evictStatusesByRecords) .catch(printErrorIfAvailable); } + +export function freeStorage() { + return navigator.storage.estimate().then(({ quota, usage }) => { + if (usage + storageMargin < quota) { + return null; + } + + return openDB().then(db => new Promise((resolve, reject) => { + const retrieval = db.transaction('accounts', 'readonly').objectStore('accounts').getAll(null, 1); + + retrieval.onsuccess = () => { + if (retrieval.result.length > 0) { + resolve(evictAccountsByRecords(retrieval.result).then(freeStorage)); + } else { + resolve(caches.delete('mastodon-system')); + } + }; + + retrieval.onerror = reject; + + db.close(); + })); + }); +} diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index 03211036c..034c35e8a 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -322,6 +322,11 @@ $small-breakpoint: 960px; border: 0; border-bottom: 1px solid rgba($ui-base-lighter-color, .6); margin: 20px 0; + + &.spacer { + height: 1px; + border: 0; + } } .container-alt { diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index e6bd0c717..6bd659030 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -145,6 +145,11 @@ border: 0; background: transparent; border-bottom: 1px solid $ui-base-color; + + &.section-break { + margin: 30px 0; + border-bottom: 2px solid $ui-base-lighter-color; + } } .muted-hint { @@ -330,6 +335,36 @@ } } +.report-note__comment { + margin-bottom: 20px; +} + +.report-note__form { + margin-bottom: 20px; + + .report-note__textarea { + box-sizing: border-box; + border: 0; + padding: 7px 4px; + margin-bottom: 10px; + font-size: 16px; + color: $ui-base-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + } + + .report-note__buttons { + text-align: right; + } + + .report-note__button { + margin: 0 0 5px 5px; + } +} + .batch-form-box { display: flex; flex-wrap: wrap; diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index c82a760c4..94e3089f8 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2455,6 +2455,10 @@ a.status-card { } } +.load-gap { + border-bottom: 1px solid lighten($ui-base-color, 8%); +} + .regeneration-indicator { text-align: center; font-size: 16px; |