diff --git a/.env.example b/.env.example index 3004daa76..b116e6d40 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,3 @@ NODE_ENV=development # BACKEND_URL="https://example.com" -# PATRON_URL="https://patron.example.com" # PROXY_HTTPS_INSECURE=false diff --git a/README.md b/README.md index 954562943..30fa61a1d 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ For https, be sure to also set `PROXY_HTTPS_INSECURE=true`. Allows using an HTTPS backend if set to `true`. -This is needed if `BACKEND_URL` or `PATRON_URL` are set to an `https://` value. +This is needed if `BACKEND_URL` is set to an `https://` value. [More info](https://stackoverflow.com/a/48624590/8811886). **Default:** `false` diff --git a/app/soapbox/actions/patron.js b/app/soapbox/actions/patron.js index 6b4885c9a..ec9a35241 100644 --- a/app/soapbox/actions/patron.js +++ b/app/soapbox/actions/patron.js @@ -5,7 +5,7 @@ export const PATRON_FUNDING_FETCH_FAIL = 'PATRON_FUNDING_FETCH_FAIL'; export function fetchFunding() { return (dispatch, getState) => { - api(getState).get('/patron/v1/funding').then(response => { + api(getState).get('/api/patron/v1/instance').then(response => { dispatch(importFetchedFunding(response.data)); }).catch(error => { dispatch(fetchFundingFail(error)); diff --git a/app/soapbox/components/media_gallery.js b/app/soapbox/components/media_gallery.js index dfe853ebc..58c79e853 100644 --- a/app/soapbox/components/media_gallery.js +++ b/app/soapbox/components/media_gallery.js @@ -11,6 +11,7 @@ import { decode } from 'blurhash'; import { isPanoramic, isPortrait, isNonConformingRatio, minimumAspectRatio, maximumAspectRatio } from '../utils/media_aspect_ratio'; import { Map as ImmutableMap } from 'immutable'; import { getSettings } from 'soapbox/actions/settings'; +import Icon from 'soapbox/components/icon'; import StillImage from 'soapbox/components/still_image'; const messages = defineMessages({ @@ -192,6 +193,24 @@ class Item extends React.PureComponent { GIF ); + } else if (attachment.get('type') === 'audio') { + const remoteURL = attachment.get('remote_url'); + const originalUrl = attachment.get('url'); + const fileExtensionLastIndex = remoteURL.lastIndexOf('.'); + const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase(); + thumbnail = ( + + + {fileExtension} + + ); } return ( diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index 844a1bc4d..69482475e 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -39,11 +39,13 @@ const messages = defineMessages({ const mapStateToProps = state => { const me = state.get('me'); const getAccount = makeGetAccount(); + const patronEnabled = state.getIn(['soapbox', 'extensions', 'patron', 'enabled']); + const patronUrl = state.getIn(['soapbox', 'extensions', 'patron', 'baseUrl']); return { account: getAccount(state, me), sidebarOpen: state.get('sidebar').sidebarOpen, - hasPatron: state.getIn(['soapbox', 'extensions', 'patron']), + patronUrl: patronEnabled && patronUrl, isStaff: isStaff(state.getIn(['accounts', me])), }; }; @@ -75,7 +77,7 @@ class SidebarMenu extends ImmutablePureComponent { } render() { - const { sidebarOpen, onClose, intl, account, onClickLogOut, hasPatron, isStaff } = this.props; + const { sidebarOpen, onClose, intl, account, onClickLogOut, patronUrl, isStaff } = this.props; if (!account) return null; const acct = account.get('acct'); @@ -127,11 +129,11 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.messages)} - {hasPatron ? - + {patronUrl ? + {intl.formatMessage(messages.donate)} - + : ''} diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.js index bc0dc1cbc..889afc01e 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.js @@ -12,7 +12,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; import Icon from 'soapbox/components/icon'; @@ -73,6 +73,7 @@ class Status extends ImmutablePureComponent { onPin: PropTypes.func, onOpenMedia: PropTypes.func, onOpenVideo: PropTypes.func, + onOpenAudio: PropTypes.func, onBlock: PropTypes.func, onEmbed: PropTypes.func, onHeightChange: PropTypes.func, @@ -194,10 +195,18 @@ class Status extends ImmutablePureComponent { return
; } + renderLoadingAudioPlayer() { + return
; + } + handleOpenVideo = (media, startTime) => { this.props.onOpenVideo(media, startTime); } + handleOpenAudio = (media, startTime) => { + this.props.OnOpenAudio(media, startTime); + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this._properStatus(), this.context.router.history); @@ -356,6 +365,24 @@ class Status extends ImmutablePureComponent { )} ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio' && status.get('media_attachments').size === 1) { + const audio = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => ( + + )} + + ); } else { media = ( diff --git a/app/soapbox/containers/status_container.js b/app/soapbox/containers/status_container.js index 0e25f9e65..d84141165 100644 --- a/app/soapbox/containers/status_container.js +++ b/app/soapbox/containers/status_container.js @@ -146,6 +146,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(openModal('VIDEO', { media, time })); }, + onOpenAudio(media, time) { + dispatch(openModal('AUDIO', { media, time })); + }, + onBlock(status) { const account = status.get('account'); dispatch(openModal('CONFIRM', { diff --git a/app/soapbox/features/account_gallery/components/media_item.js b/app/soapbox/features/account_gallery/components/media_item.js index 05f8867d4..3a59ca55c 100644 --- a/app/soapbox/features/account_gallery/components/media_item.js +++ b/app/soapbox/features/account_gallery/components/media_item.js @@ -146,6 +146,16 @@ class MediaItem extends ImmutablePureComponent { GIF
); + } else if (attachment.get('type') === 'audio') { + const remoteURL = attachment.get('remote_url'); + const fileExtensionLastIndex = remoteURL.lastIndexOf('.'); + const fileExtension = remoteURL.substr(fileExtensionLastIndex + 1).toUpperCase(); + thumbnail = ( +
+ + {fileExtension} +
+ ); } if (!visible) { diff --git a/app/soapbox/features/audio/index.js b/app/soapbox/features/audio/index.js new file mode 100644 index 000000000..c4b458e3d --- /dev/null +++ b/app/soapbox/features/audio/index.js @@ -0,0 +1,380 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { throttle } from 'lodash'; +import classNames from 'classnames'; +import Icon from 'soapbox/components/icon'; +import { getSettings } from 'soapbox/actions/settings'; + +const messages = defineMessages({ + play: { id: 'audio.play', defaultMessage: 'Play' }, + pause: { id: 'audio.pause', defaultMessage: 'Pause' }, + mute: { id: 'audio.mute', defaultMessage: 'Mute' }, + unmute: { id: 'audio.unmute', defaultMessage: 'Unmute' }, + hide: { id: 'audio.hide', defaultMessage: 'Hide audio' }, + expand: { id: 'audio.expand', defaultMessage: 'Expand audio' }, + close: { id: 'audio.close', defaultMessage: 'Close audio' }, +}); + +const formatTime = secondsNum => { + let hours = Math.floor(secondsNum / 3600); + let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); + let seconds = secondsNum - (hours * 3600) - (minutes * 60); + + if (hours < 10) hours = '0' + hours; + if (minutes < 10 && hours >= 1) minutes = '0' + minutes; + if (seconds < 10) seconds = '0' + seconds; + + return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`; +}; + +export const findElementPosition = el => { + let box; + + if (el.getBoundingClientRect && el.parentNode) { + box = el.getBoundingClientRect(); + } + + if (!box) { + return { + left: 0, + top: 0, + }; + } + + const docEl = document.documentElement; + const body = document.body; + + const clientLeft = docEl.clientLeft || body.clientLeft || 0; + const scrollLeft = window.pageXOffset || body.scrollLeft; + const left = (box.left + scrollLeft) - clientLeft; + + const clientTop = docEl.clientTop || body.clientTop || 0; + const scrollTop = window.pageYOffset || body.scrollTop; + const top = (box.top + scrollTop) - clientTop; + + return { + left: Math.round(left), + top: Math.round(top), + }; +}; + +export const getPointerPosition = (el, event) => { + const position = {}; + const box = findElementPosition(el); + const boxW = el.offsetWidth; + const boxH = el.offsetHeight; + const boxY = box.top; + const boxX = box.left; + + let pageY = event.pageY; + let pageX = event.pageX; + + if (event.changedTouches) { + pageX = event.changedTouches[0].pageX; + pageY = event.changedTouches[0].pageY; + } + + position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH)); + position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); + + return position; +}; + +const mapStateToProps = state => ({ + displayMedia: getSettings(state).get('displayMedia'), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + sensitive: PropTypes.bool, + startTime: PropTypes.number, + detailed: PropTypes.bool, + inline: PropTypes.bool, + cacheWidth: PropTypes.func, + visible: PropTypes.bool, + onToggleVisibility: PropTypes.func, + intl: PropTypes.object.isRequired, + link: PropTypes.node, + displayMedia: PropTypes.string, + expandSpoilers: PropTypes.bool, + }; + + state = { + currentTime: 0, + duration: 0, + volume: 0.5, + paused: true, + dragging: false, + muted: false, + revealed: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'), + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + volWidth = 50; + volOffset = 85; + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 125) ? 125 : offset; + } + + setPlayerRef = c => { + this.player = c; + + if (c) { + if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth); + this.setState({ + containerWidth: c.offsetWidth, + }); + } + } + + setAudioRef = c => { + this.audio = c; + + if (this.audio) { + this.setState({ volume: this.audio.volume, muted: this.audio.muted }); + } + } + + setSeekRef = c => { + this.seek = c; + } + + setVolumeRef = c => { + this.volume = c; + } + + handleClickRoot = e => e.stopPropagation(); + + handlePlay = () => { + this.setState({ paused: false }); + } + + handlePause = () => { + this.setState({ paused: true }); + } + + handleTimeUpdate = () => { + this.setState({ + currentTime: Math.floor(this.audio.currentTime), + duration: Math.floor(this.audio.duration), + }); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; //x position within the element. + + if(!isNaN(x)) { + var slideamt = x; + if(x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + this.audio.volume = slideamt; + this.setState({ volume: slideamt }); + } + }, 60); + + handleMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseMove, true); + document.addEventListener('mouseup', this.handleMouseUp, true); + document.addEventListener('touchmove', this.handleMouseMove, true); + document.addEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: true }); + this.audio.pause(); + this.handleMouseMove(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseMove, true); + document.removeEventListener('mouseup', this.handleMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseMove, true); + document.removeEventListener('touchend', this.handleMouseUp, true); + + this.setState({ dragging: false }); + this.audio.play(); + } + + handleMouseMove = throttle(e => { + const { x } = getPointerPosition(this.seek, e); + const currentTime = Math.floor(this.audio.duration * x); + + if (!isNaN(currentTime)) { + this.audio.currentTime = currentTime; + this.setState({ currentTime }); + } + }, 60); + + togglePlay = () => { + if (this.state.paused) { + this.audio.play(); + } else { + this.audio.pause(); + } + } + + toggleMute = () => { + this.audio.muted = !this.audio.muted; + this.setState({ muted: this.audio.muted }); + } + + toggleWarning = () => { + this.setState({ revealed: !this.state.revealed }); + } + + handleLoadedData = () => { + if (this.props.startTime) { + this.audio.currentTime = this.props.startTime; + this.audio.play(); + } + } + + handleProgress = () => { + if (this.audio.buffered.length > 0) { + this.setState({ buffer: this.audio.buffered.end(0) / this.audio.duration * 100 }); + } + } + + handleVolumeChange = () => { + this.setState({ volume: this.audio.volume, muted: this.audio.muted }); + } + + getPreload = () => { + const { startTime, detailed } = this.props; + const { dragging } = this.state; + + if (startTime || dragging) { + return 'auto'; + } else if (detailed) { + return 'metadata'; + } else { + return 'none'; + } + } + + render() { + const { src, inline, intl, alt, detailed, sensitive, link } = this.props; + const { currentTime, duration, volume, buffer, dragging, paused, muted, revealed } = this.state; + const progress = (currentTime / duration) * 100; + + const volumeWidth = (muted) ? 0 : volume * this.volWidth; + const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume); + const playerStyle = {}; + + let warning; + + if (sensitive) { + warning = ; + } else { + warning = ; + } + + return ( +
+ +