diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index fe573ffdaf..9bfc0e49ff 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -1,12 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import 'wicg-inert'; +import { normal } from 'color-blend'; export default class ModalRoot extends React.PureComponent { static propTypes = { children: PropTypes.node, onClose: PropTypes.func.isRequired, + backgroundColor: PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number, + }), }; activeElement = this.props.children ? document.activeElement : null; @@ -62,9 +68,7 @@ export default class ModalRoot extends React.PureComponent { Promise.resolve().then(() => { this.activeElement.focus({ preventScroll: true }); this.activeElement = null; - }).catch((error) => { - console.error(error); - }); + }).catch(console.error); } } @@ -91,10 +95,16 @@ export default class ModalRoot extends React.PureComponent { ); } + let backgroundColor = null; + + if (this.props.backgroundColor) { + backgroundColor = normal({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.3 }); + } + return (
-
+
{children}
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 8f288bdf9b..27d96e588d 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -193,22 +193,24 @@ class Status extends ImmutablePureComponent { } handleOpenVideo = (media, options) => { - this.props.onOpenVideo(media, options); + this.props.onOpenVideo(this._properStatus().get('id'), media, options); + } + + handleOpenMedia = (media, index) => { + this.props.onOpenMedia(this._properStatus().get('id'), media, index); } handleHotkeyOpenMedia = e => { const { onOpenMedia, onOpenVideo } = this.props; - const status = this._properStatus(); + const statusId = this._properStatus().get('id'); e.preventDefault(); if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - // TODO: toggle play/paused? - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 }); } else { - onOpenMedia(status.get('media_attachments'), 0); + onOpenMedia(statusId, status.get('media_attachments'), 0); } } } @@ -416,7 +418,7 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} - onOpenMedia={this.props.onOpenMedia} + onOpenMedia={this.handleOpenMedia} cacheWidth={this.props.cacheMediaWidth} defaultWidth={this.props.cachedMediaWidth} visible={this.state.showMedia} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index fe81981a78..ef520b96af 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -152,12 +152,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(mentionCompose(account, router)); }, - onOpenMedia (media, index) { - dispatch(openModal('MEDIA', { media, index })); + onOpenMedia (statusId, media, index) { + dispatch(openModal('MEDIA', { statusId, media, index })); }, - onOpenVideo (media, options) { - dispatch(openModal('VIDEO', { media, options })); + onOpenVideo (statusId, media, options) { + dispatch(openModal('VIDEO', { statusId, media, options })); }, onBlock (status) { diff --git a/app/javascript/mastodon/features/account_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index e5caec0bc2..597ca8af62 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -105,15 +105,18 @@ class AccountGallery extends ImmutablePureComponent { } handleOpenMedia = attachment => { + const { dispatch } = this.props; + const statusId = attachment.getIn(['status', 'id']); + if (attachment.get('type') === 'video') { - this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } })); + dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } })); } else if (attachment.get('type') === 'audio') { - this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } })); + dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } })); } else { const media = attachment.getIn(['status', 'media_attachments']); const index = media.findIndex(x => x.get('id') === attachment.get('id')); - this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') })); + dispatch(openModal('MEDIA', { media, index, statusId })); } } diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js index 086cda954e..1b1ec6d543 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js @@ -22,6 +22,7 @@ const messages = defineMessages({ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, + open: { id: 'status.open', defaultMessage: 'Expand this status' }, }); const makeMapStateToProps = () => { @@ -49,11 +50,19 @@ class Footer extends ImmutablePureComponent { intl: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, askReplyConfirmation: PropTypes.bool, + withOpenButton: PropTypes.bool, + onClose: PropTypes.func, }; _performReply = () => { - const { dispatch, status } = this.props; - dispatch(replyCompose(status, this.context.router.history)); + const { dispatch, status, onClose } = this.props; + const { router } = this.context; + + if (onClose) { + onClose(); + } + + dispatch(replyCompose(status, router.history)); }; handleReplyClick = () => { @@ -97,8 +106,20 @@ class Footer extends ImmutablePureComponent { } }; + handleOpenClick = e => { + const { router } = this.context; + + if (e.button !== 0 || !router) { + return; + } + + const { status } = this.props; + + router.history.push(`/statuses/${status.get('id')}`); + } + render () { - const { status, intl } = this.props; + const { status, intl, withOpenButton } = this.props; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; @@ -130,6 +151,7 @@ class Footer extends ImmutablePureComponent { + {withOpenButton && }
); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index cf3a5fa444..c5e7ba7762 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -276,22 +276,20 @@ class Status extends ImmutablePureComponent { } handleOpenMedia = (media, index) => { - this.props.dispatch(openModal('MEDIA', { media, index })); + this.props.dispatch(openModal('MEDIA', { statusId: this.props.status.get('id'), media, index })); } handleOpenVideo = (media, options) => { - this.props.dispatch(openModal('VIDEO', { media, options })); + this.props.dispatch(openModal('VIDEO', { statusId: this.props.status.get('id'), media, options })); } handleHotkeyOpenMedia = e => { - const status = this._properStatus(); + const { status } = this.props; e.preventDefault(); if (status.get('media_attachments').size > 0) { - if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { - // TODO: toggle play/paused? - } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { + if (status.getIn(['media_attachments', 0, 'type']) === 'video') { this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 }); } else { this.handleOpenMedia(status.get('media_attachments'), 0); diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js index d18f26b4ed..bcec19a493 100644 --- a/app/javascript/mastodon/features/ui/components/media_modal.js +++ b/app/javascript/mastodon/features/ui/components/media_modal.js @@ -4,13 +4,14 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'mastodon/features/video'; import classNames from 'classnames'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, injectIntl } from 'react-intl'; import IconButton from 'mastodon/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ImageLoader from './image_loader'; import Icon from 'mastodon/components/icon'; import GIFV from 'mastodon/components/gifv'; import { disableSwiping } from 'mastodon/initial_state'; +import Footer from 'mastodon/features/picture_in_picture/components/footer'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -20,15 +21,121 @@ const messages = defineMessages({ export const previewState = 'previewMediaModal'; +const digitCharacters = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'A', + 'B', + 'C', + 'D', + 'E', + 'F', + 'G', + 'H', + 'I', + 'J', + 'K', + 'L', + 'M', + 'N', + 'O', + 'P', + 'Q', + 'R', + 'S', + 'T', + 'U', + 'V', + 'W', + 'X', + 'Y', + 'Z', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + '#', + '$', + '%', + '*', + '+', + ',', + '-', + '.', + ':', + ';', + '=', + '?', + '@', + '[', + ']', + '^', + '_', + '{', + '|', + '}', + '~', +]; + +const decode83 = (str) => { + let value = 0; + let c, digit; + + for (let i = 0; i < str.length; i++) { + c = str[i]; + digit = digitCharacters.indexOf(c); + value = value * 83 + digit; + } + + return value; +}; + +const decodeRGB = int => ({ + r: Math.max(0, (int >> 16)), + g: Math.max(0, (int >> 8) & 255), + b: Math.max(0, (int & 255)), +}); + export default @injectIntl class MediaModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.list.isRequired, - status: ImmutablePropTypes.map, + statusId: PropTypes.string, index: PropTypes.number.isRequired, onClose: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + onChangeBackgroundColor: PropTypes.func.isRequired, }; static contextTypes = { @@ -67,6 +174,7 @@ class MediaModal extends ImmutablePureComponent { handleChangeIndex = (e) => { const index = Number(e.currentTarget.getAttribute('data-index')); + this.setState({ index: index % this.props.media.size, zoomButtonHidden: true, @@ -100,6 +208,22 @@ class MediaModal extends ImmutablePureComponent { this.props.onClose(); }); } + + this._sendBackgroundColor(); + } + + componentDidUpdate (prevProps, prevState) { + if (prevState.index !== this.state.index) { + this._sendBackgroundColor(); + } + } + + _sendBackgroundColor () { + const { media, onChangeBackgroundColor } = this.props; + const index = this.getIndex(); + const backgroundColor = decodeRGB(decode83(media.getIn([index, 'blurhash']).slice(2, 6))); + + onChangeBackgroundColor(backgroundColor); } componentWillUnmount () { @@ -112,6 +236,8 @@ class MediaModal extends ImmutablePureComponent { this.context.router.history.goBack(); } } + + this.props.onChangeBackgroundColor(null); } getIndex () { @@ -127,30 +253,19 @@ class MediaModal extends ImmutablePureComponent { handleStatusClick = e => { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); - this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); + this.context.router.history.push(`/statuses/${this.props.statusId}`); } } render () { - const { media, status, intl, onClose } = this.props; + const { media, statusId, intl, onClose } = this.props; const { navigationHidden } = this.state; const index = this.getIndex(); - let pagination = []; const leftNav = media.size > 1 && ; const rightNav = media.size > 1 && ; - if (media.size > 1) { - pagination = media.map((item, i) => { - const classes = ['media-modal__button']; - if (i === index) { - classes.push('media-modal__button--active'); - } - return (
  • ); - }); - } - const content = media.map((image) => { const width = image.getIn(['meta', 'original', 'width']) || null; const height = image.getIn(['meta', 'original', 'height']) || null; @@ -218,13 +333,19 @@ class MediaModal extends ImmutablePureComponent { 'media-modal__navigation--hidden': navigationHidden, }); + let pagination; + + if (media.size > 1) { + pagination = media.map((item, i) => ( + + )); + } + return (
    -
    +
    1 })}> - -
    - )} - -
      - {pagination} -
    +
    + {pagination &&
      {pagination}
    } + {statusId &&
    } +
    ); diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 5cf70a0cc2..3403830e41 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -45,6 +45,10 @@ export default class ModalRoot extends React.PureComponent { onClose: PropTypes.func.isRequired, }; + state = { + backgroundColor: null, + }; + getSnapshotBeforeUpdate () { return { visible: !!this.props.type }; } @@ -59,6 +63,10 @@ export default class ModalRoot extends React.PureComponent { } } + setBackgroundColor = color => { + this.setState({ backgroundColor: color }); + } + renderLoading = modalId => () => { return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? : null; } @@ -71,13 +79,14 @@ export default class ModalRoot extends React.PureComponent { render () { const { type, props, onClose } = this.props; + const { backgroundColor } = this.state; const visible = !!type; return ( - + {visible && ( - {(SpecificComponent) => } + {(SpecificComponent) => } )} diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js index cce6756c3e..2c3c026c8f 100644 --- a/app/javascript/mastodon/features/ui/components/video_modal.js +++ b/app/javascript/mastodon/features/ui/components/video_modal.js @@ -3,9 +3,6 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import Video from 'mastodon/features/video'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; -import Icon from 'mastodon/components/icon'; export const previewState = 'previewVideoModal'; @@ -13,7 +10,7 @@ export default class VideoModal extends ImmutablePureComponent { static propTypes = { media: ImmutablePropTypes.map.isRequired, - status: ImmutablePropTypes.map, + statusId: PropTypes.string, options: PropTypes.shape({ startTime: PropTypes.number, autoPlay: PropTypes.bool, @@ -48,15 +45,8 @@ export default class VideoModal extends ImmutablePureComponent { } } - handleStatusClick = e => { - if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { - e.preventDefault(); - this.context.router.history.push(`/statuses/${this.props.status.get('id')}`); - } - } - render () { - const { media, status, onClose } = this.props; + const { media, onClose } = this.props; const options = this.props.options || {}; return ( @@ -75,12 +65,6 @@ export default class VideoModal extends ImmutablePureComponent { alt={media.get('description')} />
    - - {status && ( -
    - -
    - )} ); } diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index 83e638ff64..46eaebd9b9 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -118,7 +118,6 @@ class Video extends React.PureComponent { deployPictureInPicture: PropTypes.func, intl: PropTypes.object.isRequired, blurhash: PropTypes.string, - link: PropTypes.node, autoPlay: PropTypes.bool, volume: PropTypes.number, muted: PropTypes.bool, @@ -534,7 +533,7 @@ class Video extends React.PureComponent { } render () { - const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props; + const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, editable, blurhash } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; const progress = Math.min((currentTime / duration) * 100, 100); const playerStyle = {}; @@ -648,8 +647,6 @@ class Video extends React.PureComponent { {formatTime(Math.floor(duration))} )} - - {link && {link}}
    diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss index f94c0aa464..fb1451cb24 100644 --- a/app/javascript/styles/mastodon/boost.scss +++ b/app/javascript/styles/mastodon/boost.scss @@ -1,22 +1,32 @@ -button.icon-button i.fa-retweet { - background-image: url("data:image/svg+xml;utf8,"); +button.icon-button { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); + } - &:hover { + &:hover i.fa-retweet { background-image: url("data:image/svg+xml;utf8,"); } -} -button.icon-button.reblogPrivate i.fa-retweet { - background-image: url("data:image/svg+xml;utf8,"); + &.reblogPrivate { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); + } - &:hover { - background-image: url("data:image/svg+xml;utf8,"); - } -} - -button.icon-button.disabled i.fa-retweet { - &, - &:hover { - background-image: url("data:image/svg+xml;utf8,"); + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); + } + } + + &.disabled { + i.fa-retweet, + &:hover i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); + } + } + + .media-modal__overlay .picture-in-picture__footer & { + i.fa-retweet { + background-image: url("data:image/svg+xml;utf8,"); + } } } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ab2a5ac617..2fc1c12de9 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1652,11 +1652,11 @@ a.account__display-name { } } -.star-icon.active { +.icon-button.star-icon.active { color: $gold-star; } -.bookmark-icon.active { +.icon-button.bookmark-icon.active { color: $red-bookmark; } @@ -3007,7 +3007,6 @@ button.icon-button i.fa-retweet { &::before { display: none !important; } - } button.icon-button.active i.fa-retweet { @@ -4487,16 +4486,19 @@ a.status-card.compact:hover { height: 100%; position: relative; - .extended-video-player { - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; + &__close, + &__zoom-button { + color: rgba($white, 0.7); - video { - max-width: $media-modal-media-max-width; - max-height: $media-modal-media-max-height; + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } + + &:focus { + background-color: rgba($white, 0.3); } } } @@ -4533,10 +4535,10 @@ a.status-card.compact:hover { } .media-modal__nav { - background: rgba($base-overlay-background, 0.5); + background: transparent; box-sizing: border-box; border: 0; - color: $primary-text-color; + color: rgba($primary-text-color, 0.7); cursor: pointer; display: flex; align-items: center; @@ -4547,6 +4549,12 @@ a.status-card.compact:hover { position: absolute; top: 0; bottom: 0; + + &:hover, + &:focus, + &:active { + color: $primary-text-color; + } } .media-modal__nav--left { @@ -4557,58 +4565,86 @@ a.status-card.compact:hover { right: 0; } -.media-modal__pagination { - width: 100%; - text-align: center; +.media-modal__overlay { + max-width: 600px; position: absolute; left: 0; - bottom: 20px; - pointer-events: none; -} + right: 0; + bottom: 0; + margin: 0 auto; -.media-modal__meta { - text-align: center; - position: absolute; - left: 0; - bottom: 20px; - width: 100%; - pointer-events: none; + .picture-in-picture__footer { + border-radius: 0; + background: transparent; + padding: 20px 0; - &--shifted { - bottom: 62px; - } + .icon-button { + color: $white; - a { - pointer-events: auto; - text-decoration: none; - font-weight: 500; - color: $ui-secondary-color; + &:hover, + &:focus, + &:active { + color: $white; + background-color: rgba($white, 0.15); + } - &:hover, - &:focus, - &:active { - text-decoration: underline; + &:focus { + background-color: rgba($white, 0.3); + } + + &.active { + color: $highlight-text-color; + + &:hover, + &:focus, + &:active { + background: rgba($highlight-text-color, 0.15); + } + + &:focus { + background: rgba($highlight-text-color, 0.3); + } + } + + &.star-icon.active { + color: $gold-star; + + &:hover, + &:focus, + &:active { + background: rgba($gold-star, 0.15); + } + + &:focus { + background: rgba($gold-star, 0.3); + } + } } } } -.media-modal__page-dot { - display: inline-block; +.media-modal__pagination { + display: flex; + justify-content: center; + margin-bottom: 20px; } -.media-modal__button { - background-color: $primary-text-color; - height: 12px; - width: 12px; - border-radius: 6px; - margin: 10px; +.media-modal__page-dot { + flex: 0 0 auto; + background-color: $white; + opacity: 0.4; + height: 6px; + width: 6px; + border-radius: 50%; + margin: 0 4px; padding: 0; border: 0; font-size: 0; -} + transition: opacity .2s ease-in-out; -.media-modal__button--active { - background-color: $highlight-text-color; + &.active { + opacity: 1; + } } .media-modal__close { diff --git a/package.json b/package.json index f4ca18e4d8..7b8f49dd8b 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "babel-runtime": "^6.26.0", "blurhash": "^1.1.3", "classnames": "^2.2.5", + "color-blend": "^3.0.0", "compression-webpack-plugin": "^6.1.1", "cross-env": "^7.0.2", "css-loader": "^5.0.1", diff --git a/yarn.lock b/yarn.lock index d6c175ea74..1a0ef66e25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2941,6 +2941,11 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" +color-blend@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color-blend/-/color-blend-3.0.0.tgz#077073ee59ebce15e084f00590c5bf7577899cb5" + integrity sha512-m21ytRyjsIkVOGG1jrrpijhx7icji0MljlxUoa0ER7lgGW11as0GPLrXQQuMULH1BWJ7OsR11Dy2S6A5lehg5A== + color-convert@^1.9.0, color-convert@^1.9.1: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"