diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..f7048a79bc090f18d557bae104e9cc4f02001d1c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,23 @@ +image: alpine:latest + +before_script: + - apk add yarn + - yarn global add node-gyp + - yarn install + +cache: + paths: + - node_modules + +test: + script: + - yarn run test:jest + +build: + script: + - yarn run build + artifacts: + paths: + - public/packs + - public/assets + expire_in: 1 week diff --git a/README.md b/README.md index 470e379dc0aae2875a0238c99286b3cb5275800b..4a2ca60ae8874a6caf006401265e3912f1112457 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,61 @@ -# Mastodon Glitch Edition # +# Mastodon Frontend, Glitch-soc + Pleroma Edition -> Now with automated deploys! +Here is a distribution of the glitch-soc frontend for pleroma. Everything from the upstream repository is kept and rebased on for easy updates, this does screws up on Merge Requests so they’ll be treated as a patchset if done here. -[![Build Status](https://img.shields.io/circleci/project/github/glitch-soc/mastodon.svg)][circleci] +# Deployement -[circleci]: https://circleci.com/gh/glitch-soc/mastodon +This is what you want to do to update the mastofe bundled with pleroma. -So here's the deal: we all work on this code, and then it runs on dev.glitch.social and anyone who uses that does so absolutely at their own risk. can you dig it? +- Run ``build.sh`` at the root of this repo, you can set the ``TARGET`` environment variable if pleroma isn’t at ``../pleroma`` (default value of ``TARGET``) +- Go to pleroma repo: + - ``git add priv/static/sw.js priv/static/packs`` + - ``git commit -m "update mastofe"`` -- You can view documentation for this project at [glitch-soc.github.io/docs/](https://glitch-soc.github.io/docs/). -- And contributing guidelines are available [here](CONTRIBUTING.md) and [here](https://glitch-soc.github.io/docs/contributing/). +# Development +## Branches +- `pleroma` branch which merges from `rebase/glitch-soc` once it is stable +- `master`: Same branch as upstream repository +- `rebase/glitch-soc`: branch which rebases from upstream, used for testing + +For developement/Merge Requests I would suggest to use `master` when you are introducing new modifications that cannot be in the upstream, and when you are changing current modifications prefer `rebase/glitch-soc`. + +Never use `pleroma` as a base for Merge Requests, it is not meant to be modified directly. + +## Tools +- Node.js +- yarn (preferred) or npm +- HTTP proxy (such as nginx) + +## nginx setup +I'll assume that you have already fired up pleroma using the installation guide. To work on the frontend while still having the backend up, use this nginx config. + +``` +server { + listen 80; + server_name pleroma.testing; + + location /packs { + add_header 'Access-Control-Allow-Origin' '*'; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + + proxy_pass http://localhost:3035; + } + + location / { + add_header 'Access-Control-Allow-Origin' '*'; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + + proxy_pass http://localhost:4000; + } +} +``` + +Change the `server_name` if you like. I personally like to create a new entry in /etc/hosts and add `127.0.0.1 pleroma.testing`, but you do what suits you. + +## Running +- Getting the node dependencies is done with `yarn install -D` (or `npm install` if you don’t have yarn) +- Launching the frontend is done with `npm run dev`. It should be reachable once it finnishes compiling. diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js index ac09adcebd070a5b1151b43a6b3360d923c3e196..4bfa6d189319246a5dad7f08d2190600fcb0a826 100644 --- a/app/javascript/flavours/glitch/actions/compose.js +++ b/app/javascript/flavours/glitch/actions/compose.js @@ -5,7 +5,6 @@ import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_sea import { useEmoji } from './emojis'; import { tagHistory } from 'flavours/glitch/util/settings'; import { recoverHashtags } from 'flavours/glitch/util/hashtag'; -import resizeImage from 'flavours/glitch/util/resize_image'; import { importFetchedAccounts } from './importer'; import { updateTimeline } from './timelines'; import { showAlertForError } from './alerts'; @@ -46,6 +45,7 @@ export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; +export const COMPOSE_CONTENT_TYPE_CHANGE = 'COMPOSE_CONTENT_TYPE_CHANGE'; export const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT'; @@ -74,12 +74,6 @@ export function changeCompose(text) { }; }; -export function cycleElefriendCompose() { - return { - type: COMPOSE_CYCLE_ELEFRIEND, - }; -}; - export function replyCompose(status, router) { return (dispatch, getState) => { dispatch({ @@ -147,6 +141,7 @@ export function submitCompose(routerHistory) { } api(getState).post('/api/v1/statuses', { status, + content_type: getState().getIn(['compose', 'content_type']), in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), media_ids: media.map(item => item.get('id')), sensitive: getState().getIn(['compose', 'sensitive']) || (spoilerText.length > 0 && media.size !== 0), @@ -225,16 +220,10 @@ export function doodleSet(options) { export function uploadCompose(files) { return function (dispatch, getState) { - const uploadLimit = 4; const media = getState().getIn(['compose', 'media_attachments']); const total = Array.from(files).reduce((a, v) => a + v.size, 0); const progress = new Array(files.length).fill(0); - if (files.length + media.size > uploadLimit) { - dispatch(showAlert(undefined, messages.uploadErrorLimit)); - return; - } - if (getState().getIn(['compose', 'poll'])) { dispatch(showAlert(undefined, messages.uploadErrorPoll)); return; @@ -242,21 +231,39 @@ export function uploadCompose(files) { dispatch(uploadComposeRequest()); - for (const [i, f] of Array.from(files).entries()) { - if (media.size + i > 3) break; - - resizeImage(f).then(file => { - const data = new FormData(); - data.append('file', file); - - return api(getState).post('/api/v1/media', data, { - onUploadProgress: function({ loaded }){ - progress[i] = loaded; - dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); - }, - }).then(({ data }) => dispatch(uploadComposeSuccess(data))); - }).catch(error => dispatch(uploadComposeFail(error))); + for (const [i, file] of Array.from(files).entries()) { + const data = new FormData(); + data.append('file', file); + + api(getState).post('/api/v1/media', data, { + onUploadProgress: function({ loaded }){ + progress[i] = loaded; + dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); + }, + }).then(function (response) { + dispatch(uploadComposeSuccess(response.data)); + }).catch(function (error) { + dispatch(uploadComposeFail(error)); + }); }; + + /* + * Previous pre-multiple upload code + * + * a372436a8... Revert: Resize images before upload in web UI to reduce bandwidth + * + * let data = new FormData(); + * data.append('file', files[0]); + * api(getState).post('/api/v1/media', data, { + * onUploadProgress: function (e) { + * dispatch(uploadComposeProgress(e.loaded, e.total)); + * }, + * }).then(function (response) { + * dispatch(uploadComposeSuccess(response.data)); + * }).catch(function (error) { + * dispatch(uploadComposeFail(error)); + * }); + */ }; }; @@ -514,6 +521,13 @@ export function changeComposeVisibility(value) { }; }; +export function changeComposeContentType(value) { + return { + type: COMPOSE_CONTENT_TYPE_CHANGE, + value, + }; +}; + export function insertEmojiCompose(position, emoji) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js index 8fdb239f7218e00b353388db2996d7921d55846a..45ca25cde5d479dbe2cefb94f57cd2df995945c1 100644 --- a/app/javascript/flavours/glitch/actions/push_notifications/registerer.js +++ b/app/javascript/flavours/glitch/actions/push_notifications/registerer.js @@ -18,7 +18,10 @@ const urlBase64ToUint8Array = (base64String) => { return outputArray; }; -const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); +const getApplicationServerKey = () => { + const k = document.querySelector('[name="applicationServerKey"]'); + return k === null ? '' : k.getAttribute('content'); +}; const getRegistration = () => navigator.serviceWorker.ready; diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js index 142a0c21a178976957f7be53861ed97e3d6ff85a..d84bd857aabc46bfabc9f93b145f34cbbf031e2c 100644 --- a/app/javascript/flavours/glitch/components/error_boundary.js +++ b/app/javascript/flavours/glitch/components/error_boundary.js @@ -55,7 +55,7 @@ export default class ErrorBoundary extends React.PureComponent { }} + values={{ issuetracker: }} /> { debugInfo !== '' && (
@@ -85,6 +85,13 @@ export default class ErrorBoundary extends React.PureComponent { /> )} +
  • + }} + /> +
  • diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js index d75edd9947d264cb2edef49b4a5168bb4d963fda..79fada42d7e66af99d934485639604bbaeb459f8 100644 --- a/app/javascript/flavours/glitch/components/hashtag.js +++ b/app/javascript/flavours/glitch/components/hashtag.js @@ -20,7 +20,7 @@ const Hashtag = ({ hashtag }) => (
    - day.get('uses')).toArray()}> + day.get('uses')).toArray()}>
    diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js index 6be2b4700a9483e481d156b80ac816831533e5ac..a5b6668966e735b26f5a96a99e998b3ab8988a97 100644 --- a/app/javascript/flavours/glitch/components/media_gallery.js +++ b/app/javascript/flavours/glitch/components/media_gallery.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { is } from 'immutable'; import IconButton from './icon_button'; +import Icon from './icon'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { isIOS } from 'flavours/glitch/util/is_mobile'; import classNames from 'classnames'; @@ -88,14 +89,19 @@ class Item extends React.PureComponent { } render () { - const { attachment, index, size, standalone, letterbox, displayWidth } = this.props; - - let width = 50; - let height = 100; - let top = 'auto'; - let left = 'auto'; - let bottom = 'auto'; - let right = 'auto'; + const { attachment, index, size, standalone, letterbox, displayWidth, truncated } = this.props; + + let width = 50; + let height = 100; + let top = 'auto'; + let left = 'auto'; + let bottom = 'auto'; + let right = 'auto'; + let ellipsis = ''; + + if (truncated && (index === 3)) { + ellipsis = (); + } if (size === 1) { width = 100; @@ -165,6 +171,7 @@ class Item extends React.PureComponent { onClick={this.handleClick} target='_blank' > + {ellipsis} ); + } else if (attachment.get('type') === 'audio') { + thumbnail = ( +
    +

    sound
    only

    +

    {attachment.get('description')}

    +
    + ); } else if (attachment.get('type') === 'gifv') { const autoPlay = !isIOS() && autoPlayGif; @@ -278,6 +300,11 @@ export default class MediaGallery extends React.PureComponent { const { media, intl, sensitive, letterbox, fullwidth, defaultWidth } = this.props; const { visible } = this.state; const size = media.take(4).size; + let truncated = false; + + if (size < media.size) { + truncated = true; + } const width = this.state.width || defaultWidth; @@ -308,7 +335,7 @@ export default class MediaGallery extends React.PureComponent { if (this.isStandaloneEligible()) { children = ; } else { - children = media.take(4).map((attachment, i) => ); + children = media.take(4).map((attachment, i) => ); } } diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index c8bf75f790f2dcbe36128512eaafdb47d340cfd6..a6140b81b3bc06b2187622fd4d3dac69ff762a88 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -10,7 +10,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 'flavours/glitch/util/async-components'; +import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components'; import { HotKeys } from 'react-hotkeys'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import classNames from 'classnames'; @@ -364,6 +364,10 @@ export default class Status extends ImmutablePureComponent { return
    ; } + renderLoadingAudioPlayer () { + return
    ; + } + render () { const { handleRef, @@ -449,6 +453,14 @@ export default class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const audio = status.getIn(['media_attachments', 0]); + + media = ( + + {Component => } + + ); } else if (attachments.getIn([0, 'type']) === 'video') { // Media type is 'video' const video = status.getIn(['media_attachments', 0]); diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index 1d3130604cd6dfa5252b5f41fc8baface0b71790..bb8d4802c4457310c6a7f29236384816bfb7b8d9 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -5,7 +5,7 @@ import IconButton from './icon_button'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me, isStaff } from 'flavours/glitch/util/initial_state'; +import { me, isStaff, deleteOthersNotice } from 'flavours/glitch/util/initial_state'; import RelativeTimestamp from './relative_timestamp'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links'; @@ -218,7 +218,6 @@ export default class StatusActionBar extends ImmutablePureComponent { } menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); - menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); } 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 }); @@ -242,6 +241,9 @@ export default class StatusActionBar extends ImmutablePureComponent { }); } } + if ( deleteOthersNotice ) { + menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); + } } if (status.get('in_reply_to_id', null) === null) { diff --git a/app/javascript/flavours/glitch/containers/media_container.js b/app/javascript/flavours/glitch/containers/media_container.js index 1b480658f0962ede64fe44af5d83a9ca2bd49ade..fd95faad555518b2ac2f5f11725353ad96d7278e 100644 --- a/app/javascript/flavours/glitch/containers/media_container.js +++ b/app/javascript/flavours/glitch/containers/media_container.js @@ -5,6 +5,7 @@ import { IntlProvider, addLocaleData } from 'react-intl'; import { getLocale } from 'mastodon/locales'; import MediaGallery from 'flavours/glitch/components/media_gallery'; import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; import Card from 'flavours/glitch/features/status/components/card'; import Poll from 'flavours/glitch/components/poll'; import ModalRoot from 'flavours/glitch/components/modal_root'; @@ -14,7 +15,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Audio, Card, Poll }; export default class MediaContainer extends PureComponent { diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js new file mode 100644 index 0000000000000000000000000000000000000000..f36a995a9130adff5d3791f4c826001e1d2d0653 --- /dev/null +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -0,0 +1,313 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import { throttle } from 'lodash'; +import classNames from 'classnames'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, +}); + +const formatTime = secondsNum => { + let hours = Math.floor(secondsNum / 3600); + let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); + let seconds = Math.floor(secondsNum - (hours * 3600) - (minutes * 60)); + + if (hours < 10) hours = '0' + hours; + if (minutes < 10) 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; +}; + +// hard coded in components.scss +// any way to get ::before values programatically? +const VOL_WIDTH = 50; +const VOL_OFFSET = 70; + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + duration: PropTypes.number, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: 0, + volume: 0.5, + paused: true, + dragging: false, + fullscreen: false, + muted: false, + }; + + volHandleOffset = v => { + const offset = v * VOL_WIDTH + VOL_OFFSET; + return (offset > 110) ? 110 : offset; + } + + componentDidMount () { + this.setState({ duration: this.props.duration }); + } + + componentWillReceiveProps (nextProps) { + this.setState({ duration: nextProps.duration }); + } + + 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) / VOL_WIDTH; //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 }); + } + + 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 }); + } + + render () { + const { src, intl } = this.props; + const { currentTime, duration, volume, buffer, dragging, paused, muted } = this.state; + const progress = (currentTime / duration) * 100; + const volumeWidth = (muted) ? 0 : volume * VOL_WIDTH; + const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( +
    +