diff options
Diffstat (limited to 'app/javascript/flavours/glitch')
23 files changed, 534 insertions, 196 deletions
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 170efad04..88994c2ac 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -486,13 +486,30 @@ class Status extends ImmutablePureComponent { return null; } + const handlers = { + reply: this.handleHotkeyReply, + favourite: this.handleHotkeyFavourite, + boost: this.handleHotkeyBoost, + mention: this.handleHotkeyMention, + open: this.handleHotkeyOpen, + openProfile: this.handleHotkeyOpenProfile, + moveUp: this.handleHotkeyMoveUp, + moveDown: this.handleHotkeyMoveDown, + toggleSpoiler: this.handleExpandedToggle, + bookmark: this.handleHotkeyBookmark, + toggleCollapse: this.handleHotkeyCollapse, + toggleSensitive: this.handleHotkeyToggleSensitive, + }; + if (hidden) { return ( - <div ref={this.handleRef}> - {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} - {' '} - {status.get('content')} - </div> + <HotKeys handlers={handlers}> + <div ref={this.handleRef} className='status focusable' tabIndex='0'> + {status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])} + {' '} + {status.get('content')} + </div> + </HotKeys> ); } @@ -628,21 +645,6 @@ class Status extends ImmutablePureComponent { rebloggedByText = intl.formatMessage({ id: 'status.reblogged_by', defaultMessage: '{name} boosted' }, { name: account.get('acct') }); } - const handlers = { - reply: this.handleHotkeyReply, - favourite: this.handleHotkeyFavourite, - boost: this.handleHotkeyBoost, - mention: this.handleHotkeyMention, - open: this.handleHotkeyOpen, - openProfile: this.handleHotkeyOpenProfile, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - toggleSpoiler: this.handleExpandedToggle, - bookmark: this.handleHotkeyBookmark, - toggleCollapse: this.handleHotkeyCollapse, - toggleSensitive: this.handleHotkeyToggleSensitive, - }; - const computedClass = classNames('status', `status-${status.get('visibility')}`, { collapsed: isCollapsed, 'has-background': isCollapsed && background, diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js new file mode 100644 index 000000000..0ecfc9141 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/character_counter.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { length } from 'stringz'; + +export default class CharacterCounter extends React.PureComponent { + + static propTypes = { + text: PropTypes.string.isRequired, + max: PropTypes.number.isRequired, + }; + + checkRemainingText (diff) { + if (diff < 0) { + return <span className='character-counter character-counter--over'>{diff}</span>; + } + + return <span className='character-counter'>{diff}</span>; + } + + render () { + const diff = this.props.max - length(this.props.text); + return this.checkRemainingText(diff); + } + +} diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index 3d9002fe4..6e07998ec 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -15,6 +15,8 @@ import { countableText } from 'flavours/glitch/util/counter'; import OptionsContainer from '../containers/options_container'; import Publisher from './publisher'; import TextareaIcons from './textarea_icons'; +import { maxChars } from 'flavours/glitch/util/initial_state'; +import CharacterCounter from './character_counter'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -119,14 +121,8 @@ class ComposeForm extends ImmutablePureComponent { // Submit unless there are media with missing descriptions if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) { - const firstWithoutDescription = media.findIndex(item => !item.get('description')); - if (uploadForm) { - const inputs = uploadForm.querySelectorAll('.composer--upload_form--item input'); - if (inputs.length == media.size && firstWithoutDescription !== -1) { - inputs[firstWithoutDescription].focus(); - } - } - onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null); + const firstWithoutDescription = media.find(item => !item.get('description')); + onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id')); } else if (onSubmit) { onSubmit(this.context.router ? this.context.router.history : null); } @@ -298,6 +294,8 @@ class ComposeForm extends ImmutablePureComponent { let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia); + const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`; + return ( <div className='composer'> <WarningContainer /> @@ -347,19 +345,24 @@ class ComposeForm extends ImmutablePureComponent { </div> </AutosuggestTextarea> - <OptionsContainer - advancedOptions={advancedOptions} - disabled={isSubmitting} - onChangeVisibility={onChangeVisibility} - onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} - onUpload={onPaste} - privacy={privacy} - sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} - spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} - /> + <div className='composer--options-wrapper'> + <OptionsContainer + advancedOptions={advancedOptions} + disabled={isSubmitting} + onChangeVisibility={onChangeVisibility} + onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness} + onUpload={onPaste} + privacy={privacy} + sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)} + spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler} + /> + <div className='compose--counter-wrapper'> + <CharacterCounter text={countText} max={maxChars} /> + </div> + </div> <Publisher - countText={`${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`} + countText={countText} disabled={disabledButton} onSecondarySubmit={handleSecondarySubmit} onSubmit={handleSubmit} diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js index e283b32b9..f5eafc6fd 100644 --- a/app/javascript/flavours/glitch/features/compose/components/publisher.js +++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js @@ -49,7 +49,6 @@ class Publisher extends ImmutablePureComponent { return ( <div className={computedClass}> - <span className='count'>{diff}</span> {sideArm && sideArm !== 'none' ? ( <Button className='side_arm' diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js index 84edf664e..f89145a52 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload.js @@ -4,18 +4,12 @@ import PropTypes from 'prop-types'; import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; -const messages = defineMessages({ - description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, -}); - -// The component. -export default @injectIntl -class Upload extends ImmutablePureComponent { +export default class Upload extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, @@ -23,30 +17,10 @@ class Upload extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - intl: PropTypes.object.isRequired, onUndo: PropTypes.func.isRequired, - onDescriptionChange: PropTypes.func.isRequired, onOpenFocalPoint: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, - }; - - state = { - hovered: false, - focused: false, - dirtyDescription: null, }; - handleKeyDown = (e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - this.handleSubmit(); - } - } - - handleSubmit = () => { - this.handleInputBlur(); - this.props.onSubmit(this.context.router.history); - } - handleUndoClick = e => { e.stopPropagation(); this.props.onUndo(this.props.media.get('id')); @@ -57,69 +31,21 @@ class Upload extends ImmutablePureComponent { this.props.onOpenFocalPoint(this.props.media.get('id')); } - handleInputChange = e => { - this.setState({ dirtyDescription: e.target.value }); - } - - handleMouseEnter = () => { - this.setState({ hovered: true }); - } - - handleMouseLeave = () => { - this.setState({ hovered: false }); - } - - handleInputFocus = () => { - this.setState({ focused: true }); - } - - handleClick = () => { - this.setState({ focused: true }); - } - - handleInputBlur = () => { - const { dirtyDescription } = this.state; - - this.setState({ focused: false, dirtyDescription: null }); - - if (dirtyDescription !== null) { - this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription); - } - } - render () { const { intl, media } = this.props; - const active = this.state.hovered || this.state.focused || isUserTouching(); - const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; - const computedClass = classNames('composer--upload_form--item', { active }); const focusX = media.getIn(['meta', 'focus', 'x']); const focusY = media.getIn(['meta', 'focus', 'y']); const x = ((focusX / 2) + .5) * 100; const y = ((focusY / -2) + .5) * 100; return ( - <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'> + <div className='composer--upload_form--item' tabIndex='0' role='button'> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}> {({ scale }) => ( <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}> - <div className={classNames('composer--upload_form--actions', { active })}> + <div className={classNames('composer--upload_form--actions', { active: true })}> <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button> - {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>} - </div> - - <div className={classNames('composer--upload_form--description', { active })}> - <label> - <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span> - <textarea - placeholder={intl.formatMessage(messages.description)} - value={description} - maxLength={420} - onFocus={this.handleInputFocus} - onChange={this.handleInputChange} - onBlur={this.handleInputBlur} - onKeyDown={this.handleKeyDown} - /> - </label> + <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button> </div> </div> )} diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js index 35880ddcc..43039c674 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js @@ -4,6 +4,7 @@ import UploadProgressContainer from '../containers/upload_progress_container'; import ImmutablePureComponent from 'react-immutable-pure-component'; import UploadContainer from '../containers/upload_container'; import SensitiveButtonContainer from '../containers/sensitive_button_container'; +import { FormattedMessage } from 'react-intl'; export default class UploadForm extends ImmutablePureComponent { static propTypes = { @@ -15,7 +16,7 @@ export default class UploadForm extends ImmutablePureComponent { return ( <div className='composer--upload_form'> - <UploadProgressContainer /> + <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} /> {mediaIds.size > 0 && ( <div className='content'> diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js index 264c563f2..b00612983 100644 --- a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js +++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js @@ -2,7 +2,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import Motion from 'flavours/glitch/util/optional_motion'; import spring from 'react-motion/lib/spring'; -import { FormattedMessage } from 'react-intl'; import Icon from 'flavours/glitch/components/icon'; export default class UploadProgress extends React.PureComponent { @@ -10,10 +9,12 @@ export default class UploadProgress extends React.PureComponent { static propTypes = { active: PropTypes.bool, progress: PropTypes.number, + icon: PropTypes.string.isRequired, + message: PropTypes.node.isRequired, }; render () { - const { active, progress } = this.props; + const { active, progress, icon, message } = this.props; if (!active) { return null; @@ -21,10 +22,10 @@ export default class UploadProgress extends React.PureComponent { return ( <div className='composer--upload_form--progress'> - <Icon icon='upload' /> + <Icon icon={icon} /> <div className='message'> - <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' /> + {message} <div className='backdrop'> <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}> diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index 199d43913..18e2b2f39 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -25,6 +25,8 @@ const messages = defineMessages({ defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' }, missingDescriptionConfirm: { id: 'confirmations.missing_media_description.confirm', defaultMessage: 'Send anyway' }, + missingDescriptionEdit: { id: 'confirmations.missing_media_description.edit', + defaultMessage: 'Edit media' }, }); // State mapping. @@ -112,11 +114,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(changeComposeVisibility(value)); }, - onMediaDescriptionConfirm(routerHistory) { + onMediaDescriptionConfirm(routerHistory, mediaId) { dispatch(openModal('CONFIRM', { message: intl.formatMessage(messages.missingDescriptionMessage), confirm: intl.formatMessage(messages.missingDescriptionConfirm), onConfirm: () => dispatch(submitCompose(routerHistory)), + secondary: intl.formatMessage(messages.missingDescriptionEdit), + onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })), onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)), })); }, diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js index d6bff63ac..f687fae99 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import Upload from '../components/upload'; -import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose'; +import { undoUploadCompose } from 'flavours/glitch/actions/compose'; import { openModal } from 'flavours/glitch/actions/modal'; import { submitCompose } from 'flavours/glitch/actions/compose'; @@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({ dispatch(undoUploadCompose(id)); }, - onDescriptionChange: (id, description) => { - dispatch(changeUploadCompose(id, { description })); - }, - onOpenFocalPoint: id => { dispatch(openModal('FOCAL_POINT', { id })); }, 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 57c92cc66..c4cc18f94 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 @@ -1,11 +1,26 @@ import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; -import ImageLoader from './image_loader'; import classNames from 'classnames'; import { changeUploadCompose } from 'flavours/glitch/actions/compose'; import { getPointerPosition } from 'flavours/glitch/features/video'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; +import IconButton from 'flavours/glitch/components/icon_button'; +import Button from 'flavours/glitch/components/button'; +import Video from 'flavours/glitch/features/video'; +import Textarea from 'react-textarea-autosize'; +import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; +import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; +import { length } from 'stringz'; +import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' }, + placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' }, +}); const mapStateToProps = (state, { id }) => ({ media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), @@ -13,17 +28,26 @@ const mapStateToProps = (state, { id }) => ({ const mapDispatchToProps = (dispatch, { id }) => ({ - onSave: (x, y) => { - dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` })); + onSave: (description, x, y) => { + dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` })); }, }); -@connect(mapStateToProps, mapDispatchToProps) -export default class FocalPointModal extends ImmutablePureComponent { +const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') + .replace(/\n/g, ' ') + .replace(/\*\*\*\*\*\*/g, '\n\n'); + +const assetHost = process.env.CDN_HOST || ''; + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class FocalPointModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, }; state = { @@ -32,6 +56,9 @@ export default class FocalPointModal extends ImmutablePureComponent { focusX: 0, focusY: 0, dragging: false, + description: '', + dirty: false, + progress: 0, }; componentWillMount () { @@ -57,6 +84,14 @@ export default class FocalPointModal extends ImmutablePureComponent { this.setState({ dragging: true }); } + handleTouchStart = e => { + document.addEventListener('touchmove', this.handleMouseMove); + document.addEventListener('touchend', this.handleTouchEnd); + + this.updatePosition(e); + this.setState({ dragging: true }); + } + handleMouseMove = e => { this.updatePosition(e); } @@ -66,7 +101,13 @@ export default class FocalPointModal extends ImmutablePureComponent { document.removeEventListener('mouseup', this.handleMouseUp); this.setState({ dragging: false }); - this.props.onSave(this.state.focusX, this.state.focusY); + } + + handleTouchEnd = () => { + document.removeEventListener('touchmove', this.handleMouseMove); + document.removeEventListener('touchend', this.handleTouchEnd); + + this.setState({ dragging: false }); } updatePosition = e => { @@ -74,46 +115,145 @@ export default class FocalPointModal extends ImmutablePureComponent { const focusX = (x - .5) * 2; const focusY = (y - .5) * -2; - this.setState({ x, y, focusX, focusY }); + this.setState({ x, y, focusX, focusY, dirty: true }); } updatePositionFromMedia = media => { - const focusX = media.getIn(['meta', 'focus', 'x']); - const focusY = media.getIn(['meta', 'focus', 'y']); + const focusX = media.getIn(['meta', 'focus', 'x']); + const focusY = media.getIn(['meta', 'focus', 'y']); + const description = media.get('description') || ''; if (focusX && focusY) { const x = (focusX / 2) + .5; const y = (focusY / -2) + .5; - this.setState({ x, y, focusX, focusY }); + this.setState({ + x, + y, + focusX, + focusY, + description, + dirty: false, + }); } else { - this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 }); + this.setState({ + x: 0.5, + y: 0.5, + focusX: 0, + focusY: 0, + description, + dirty: false, + }); } } + handleChange = e => { + this.setState({ description: e.target.value, dirty: true }); + } + + handleSubmit = () => { + this.props.onSave(this.state.description, this.state.focusX, this.state.focusY); + this.props.onClose(); + } + setRef = c => { this.node = c; } - render () { + handleTextDetection = () => { const { media } = this.props; - const { x, y, dragging } = this.state; + + this.setState({ detecting: true }); + + fetchTesseract().then(({ TesseractWorker }) => { + const worker = new TesseractWorker({ + workerPath: `${assetHost}/packs/ocr/worker.min.js`, + corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`, + langPath: `${assetHost}/ocr/lang-data`, + }); + + worker.recognize(media.get('url')) + .progress(({ progress }) => this.setState({ progress })) + .finally(() => worker.terminate()) + .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false })) + .catch(() => this.setState({ detecting: false })); + }).catch(() => this.setState({ detecting: false })); + } + + render () { + const { media, intl, onClose } = this.props; + const { x, y, dragging, description, dirty, detecting, progress } = this.state; const width = media.getIn(['meta', 'original', 'width']) || null; const height = media.getIn(['meta', 'original', 'height']) || null; + const focals = ['image', 'gifv'].includes(media.get('type')); + + const previewRatio = 16/9; + const previewWidth = 200; + const previewHeight = previewWidth / previewRatio; return ( - <div className='modal-root__modal video-modal focal-point-modal'> - <div className={classNames('focal-point', { dragging })} ref={this.setRef}> - <ImageLoader - previewSrc={media.get('preview_url')} - src={media.get('url')} - width={width} - height={height} - /> - - <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> - <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} /> + <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}> + <div className='report-modal__target'> + <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} /> + <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' /> + </div> + + <div className='report-modal__container'> + <div className='report-modal__comment'> + {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>} + + <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label> + + <div className='setting-text__wrapper'> + <Textarea + id='upload-modal__description' + className='setting-text light' + value={detecting ? '…' : description} + onChange={this.handleChange} + disabled={detecting} + autoFocus + /> + + <div className='setting-text__modifiers'> + <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} /> + </div> + </div> + + <div className='setting-text__toolbar'> + <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button> + <CharacterCounter max={420} text={detecting ? '' : description} /> + </div> + + <Button disabled={!dirty || detecting || length(description) > 420} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} /> + </div> + + <div className='focal-point-modal__content'> + {focals && ( + <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> + {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />} + {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />} + + <div className='focal-point__preview'> + <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> + <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} /> + </div> + + <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} /> + <div className='focal-point__overlay' /> + </div> + )} + + {['audio', 'video'].includes(media.get('type')) && ( + <Video + preview={media.get('preview_url')} + blurhash={media.get('blurhash')} + src={media.get('url')} + detailed + editable + /> + )} + </div> </div> </div> ); diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index 112f9d101..6d5162519 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -101,6 +101,7 @@ export default class Video extends React.PureComponent { fullwidth: PropTypes.bool, detailed: PropTypes.bool, inline: PropTypes.bool, + editable: PropTypes.bool, cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, visible: PropTypes.bool, @@ -393,7 +394,7 @@ export default class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props; + const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = (currentTime / duration) * 100; const playerStyle = {}; @@ -401,7 +402,7 @@ export default class Video extends React.PureComponent { const volumeWidth = (muted) ? 0 : volume * this.volWidth; const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume); - const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth }); + const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth }); let { width, height } = this.props; @@ -443,7 +444,7 @@ export default class Video extends React.PureComponent { > <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} /> - {revealed && <video + {(revealed || editable) && <video ref={this.setVideoRef} src={src} poster={preview} @@ -465,7 +466,7 @@ export default class Video extends React.PureComponent { onVolumeChange={this.handleVolumeChange} />} - <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}> + <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}> <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}> <span className='spoiler-button__overlay__label'>{warning}</span> </button> @@ -508,7 +509,7 @@ export default class Video extends React.PureComponent { </div> <div className='video-player__buttons right'> - {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>} + {(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>} {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>} {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>} <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button> diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index 9f88b0e04..72725d20b 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -104,6 +104,15 @@ function main() { delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original')); delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static')); + + delegate(document, '.blocks-table button.icon-button', 'click', function(e) { + e.preventDefault(); + + const classList = this.firstElementChild.classList; + classList.toggle('fa-chevron-down'); + classList.toggle('fa-chevron-up'); + this.parentElement.parentElement.nextElementSibling.classList.toggle('hidden'); + }); }); } diff --git a/app/javascript/flavours/glitch/styles/basics.scss b/app/javascript/flavours/glitch/styles/basics.scss index 550b7fdfc..4de3955a6 100644 --- a/app/javascript/flavours/glitch/styles/basics.scss +++ b/app/javascript/flavours/glitch/styles/basics.scss @@ -133,3 +133,17 @@ button { outline: 0 !important; } } + +.layout-single-column .app-holder { + &, + & > div { + min-height: 100vh; + } +} + +.layout-multiple-columns .app-holder { + &, + & > div { + height: 100%; + } +} diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss index 1044b13c1..c4fa4f654 100644 --- a/app/javascript/flavours/glitch/styles/components/composer.scss +++ b/app/javascript/flavours/glitch/styles/components/composer.scss @@ -2,6 +2,18 @@ padding: 10px; } +.character-counter { + cursor: default; + font-family: $font-sans-serif, sans-serif; + font-size: 14px; + font-weight: 600; + color: $lighter-text-color; + + &.character-counter--over { + color: $warning-red; + } +} + .no-reduce-motion .composer--spoiler { transition: height 0.4s ease, opacity 0.4s ease; } @@ -489,12 +501,18 @@ background: $simple-background-color; } -.composer--options { +.composer--options-wrapper { padding: 10px; background: darken($simple-background-color, 8%); - box-shadow: inset 0 5px 5px rgba($base-shadow-color, 0.05); border-radius: 0 0 4px 4px; height: 27px; + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.composer--options { + display: flex; flex: 0 0 auto; & > * { @@ -519,6 +537,11 @@ } } +.compose--counter-wrapper { + align-self: center; + margin-right: 4px; +} + .composer--options--dropdown { &.open { & > .value { @@ -589,13 +612,6 @@ justify-content: flex-end; flex: 0 0 auto; - & > .count { - display: inline-block; - margin: 0 16px 0 8px; - font-size: 16px; - line-height: 36px; - } - & > .primary { display: inline-block; margin: 0; diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 6942170f2..f453a046e 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -3,6 +3,27 @@ -ms-overflow-style: -ms-autohiding-scrollbar; } +.link-button { + display: block; + font-size: 15px; + line-height: 20px; + color: $ui-highlight-color; + border: 0; + background: transparent; + padding: 0; + cursor: pointer; + + &:hover, + &:active { + text-decoration: underline; + } + + &:disabled { + color: $ui-primary-color; + cursor: default; + } +} + .button { background-color: darken($ui-highlight-color, 3%); border: 10px none; diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss index 8b5d0486d..39ffcae9d 100644 --- a/app/javascript/flavours/glitch/styles/components/media.scss +++ b/app/javascript/flavours/glitch/styles/components/media.scss @@ -338,6 +338,11 @@ position: relative; background: $base-shadow-color; max-width: 100%; + border-radius: 4px; + + &.editable { + border-radius: 0; + } &:focus { outline: 0; diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss index a98efee9f..ec32c9114 100644 --- a/app/javascript/flavours/glitch/styles/components/modal.scss +++ b/app/javascript/flavours/glitch/styles/components/modal.scss @@ -528,7 +528,8 @@ } } -.report-modal__statuses { +.report-modal__statuses, +.focal-point-modal__content { flex: 1 1 auto; min-height: 20vh; max-height: 80vh; @@ -544,6 +545,12 @@ } } +.focal-point-modal__content { + @media screen and (max-width: 480px) { + max-height: 40vh; + } +} + .report-modal__comment { padding: 20px; border-right: 1px solid $ui-secondary-color; @@ -565,16 +572,56 @@ padding: 10px; font-family: inherit; font-size: 14px; - resize: vertical; + resize: none; border: 0; outline: 0; border-radius: 4px; border: 1px solid $ui-secondary-color; - margin-bottom: 20px; + min-height: 100px; + max-height: 50vh; + margin-bottom: 10px; &:focus { border: 1px solid darken($ui-secondary-color, 8%); } + + &__wrapper { + background: $white; + border: 1px solid $ui-secondary-color; + margin-bottom: 10px; + border-radius: 4px; + + .setting-text { + border: 0; + margin-bottom: 0; + border-radius: 0; + + &:focus { + border: 0; + } + } + + &__modifiers { + color: $inverted-text-color; + font-family: inherit; + font-size: 14px; + background: $white; + } + } + + &__toolbar { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + } + } + + .setting-text-label { + display: block; + color: $inverted-text-color; + font-size: 14px; + font-weight: 500; + margin-bottom: 10px; } .setting-toggle { @@ -598,15 +645,6 @@ } } -.report-modal__target { - padding: 20px; - - .media-modal__close { - top: 19px; - right: 15px; - } -} - .actions-modal { .status { overflow-y: auto; @@ -725,6 +763,15 @@ } } +.report-modal__target { + padding: 15px; + + .media-modal__close { + top: 14px; + right: 15px; + } +} + .embed-modal { max-width: 80vw; max-height: 80vh; @@ -787,19 +834,23 @@ .focal-point { position: relative; - cursor: pointer; + cursor: move; overflow: hidden; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: $base-shadow-color; - &.dragging { - cursor: move; - } - - img { - max-width: 80vw; + img, + video { + display: block; max-height: 80vh; - width: auto; + width: 100%; height: auto; - margin: auto; + margin: 0; + object-fit: contain; + background: $base-shadow-color; } &__reticle { @@ -819,6 +870,43 @@ top: 0; left: 0; } + + &__preview { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 2; + cursor: move; + transition: opacity 0.1s ease; + + &:hover { + opacity: 0.5; + } + + strong { + color: $primary-text-color; + font-size: 14px; + font-weight: 500; + display: block; + margin-bottom: 5px; + } + + div { + border-radius: 4px; + box-shadow: 0 0 14px rgba($base-shadow-color, 0.2); + } + } + + @media screen and (max-width: 480px) { + img, + video { + max-height: 100%; + } + + &__preview { + display: none; + } + } } .filtered-status-info { diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index 7da8edbde..35a8ce7a3 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -135,13 +135,12 @@ } } - .composer--options { + .composer--options-wrapper { background: lighten($ui-base-color, 10%); - box-shadow: unset; + } - & > hr { - display: none; - } + .composer--options > hr { + display: none; } .composer--options--dropdown--content--item { diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss index 154844665..bf67388f0 100644 --- a/app/javascript/flavours/glitch/styles/tables.scss +++ b/app/javascript/flavours/glitch/styles/tables.scss @@ -237,3 +237,70 @@ a.table-action-link { } } } + +.blocks-table { + width: 100%; + max-width: 100%; + border-spacing: 0; + border-collapse: collapse; + table-layout: fixed; + border: 1px solid darken($ui-base-color, 8%); + + thead { + border: 1px solid darken($ui-base-color, 8%); + background: darken($ui-base-color, 4%); + font-weight: 500; + + th.severity-column { + width: 120px; + } + + th.button-column { + width: 23px; + } + } + + tbody > tr { + border: 1px solid darken($ui-base-color, 8%); + border-bottom: 0; + background: darken($ui-base-color, 4%); + + &:hover { + background: darken($ui-base-color, 2%); + } + + &.even { + background: $ui-base-color; + + &:hover { + background: lighten($ui-base-color, 2%); + } + } + + &.rationale { + background: lighten($ui-base-color, 4%); + border-top: 0; + + &:hover { + background: lighten($ui-base-color, 6%); + } + + &.hidden { + display: none; + } + } + + td:first-child { + overflow: hidden; + text-overflow: ellipsis; + } + } + + th, + td { + padding: 8px; + line-height: 18px; + vertical-align: top; + text-align: left; + } +} diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss index acaf5b024..e207113be 100644 --- a/app/javascript/flavours/glitch/styles/widgets.scss +++ b/app/javascript/flavours/glitch/styles/widgets.scss @@ -109,6 +109,15 @@ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2); } +.placeholder-widget { + padding: 16px; + border-radius: 4px; + border: 2px dashed $dark-text-color; + text-align: center; + color: $darker-text-color; + margin-bottom: 10px; +} + .contact-widget, .landing-page__information.contact-widget { box-sizing: border-box; @@ -521,6 +530,12 @@ $fluid-breakpoint: $maximum-width + 20px; a { font-size: 14px; line-height: 20px; + } +} + +.notice-widget, +.placeholder-widget { + a { text-decoration: none; font-weight: 500; color: $ui-highlight-color; diff --git a/app/javascript/flavours/glitch/util/async-components.js b/app/javascript/flavours/glitch/util/async-components.js index f2aeda834..8f2e4c6e4 100644 --- a/app/javascript/flavours/glitch/util/async-components.js +++ b/app/javascript/flavours/glitch/util/async-components.js @@ -153,3 +153,7 @@ export function ListAdder () { export function Search () { return import(/*webpackChunkName: "features/glitch/async/search" */'flavours/glitch/features/search'); } + +export function Tesseract () { + return import(/*webpackChunkName: "tesseract" */'tesseract.js'); +} diff --git a/app/javascript/flavours/glitch/util/numbers.js b/app/javascript/flavours/glitch/util/numbers.js index fdd8269ae..f7e4ceb93 100644 --- a/app/javascript/flavours/glitch/util/numbers.js +++ b/app/javascript/flavours/glitch/util/numbers.js @@ -4,7 +4,9 @@ import { FormattedNumber } from 'react-intl'; export const shortNumberFormat = number => { if (number < 1000) { return <FormattedNumber value={number} />; - } else { + } else if (number < 1000000) { return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>; + } else { + return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>; } }; diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js index a8ec5f3fa..d566edb03 100644 --- a/app/javascript/flavours/glitch/util/resize_image.js +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -71,7 +71,7 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) = // and return an all-white image instead. Assume reading failed if the resized // image is perfectly white. const imageData = context.getImageData(0, 0, width, height); - if (imageData.every(value => value === 255)) { + if (imageData.data.every(value => value === 255)) { throw 'Failed to read from canvas'; } |