[WIP] Initial status work

This commit is contained in:
kibigo!
2017-08-14 15:07:22 -07:00
parent 4dc0ddc601
commit 866e441df3
64 changed files with 4237 additions and 2260 deletions

View File

@@ -0,0 +1,191 @@
// <StatusContentGallery>
// ======================
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Our imports.
import StatusContentGalleryItem from './item';
import StatusContentGalleryPlayer from './player';
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
hide: { id: 'media_gallery.hide_media', defaultMessage: 'Hide media' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGallery extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachments: ImmutablePropTypes.list.isRequired,
autoPlayGif: PropTypes.bool,
fullwidth: PropTypes.bool,
height: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onOpenMedia: PropTypes.func.isRequired,
onOpenVideo: PropTypes.func.isRequired,
sensitive: PropTypes.bool,
standalone: PropTypes.bool,
};
state = {
visible: !this.props.sensitive,
};
// Handles media clicks.
handleMediaClick = index => {
const { attachments, onOpenMedia, standalone } = this.props;
if (standalone) return;
onOpenMedia(attachments, index);
}
// Handles showing and hiding.
handleToggle = () => {
this.setState({ visible: !this.state.visible });
}
// Handles video clicks.
handleVideoClick = time => {
const { attachments, onOpenVideo, standalone } = this.props;
if (standalone) return;
onOpenVideo(attachments.get(0), time);
}
// Renders.
render () {
const { handleMediaClick, handleToggle, handleVideoClick } = this;
const {
attachments,
autoPlayGif,
fullwidth,
intl,
letterbox,
sensitive,
} = this.props;
const { visible } = this.state;
const computedClass = classNames('glitch', 'glitch__status__content__gallery', {
_fullwidth: fullwidth,
});
const useableAttachments = attachments.take(4);
let button;
let children;
let size;
// This handles hidden media
if (!this.state.visible) {
button = (
<CommonButton
active
className='gallery\sensitive gallery\curtain'
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
>
<span className='gallery\message'>
<strong className='gallery\warning'>
{sensitive ? (
<FormattedMessage
id='status.sensitive_warning'
defaultMessage='Sensitive content'
/>
) : (
<FormattedMessage
id='status.media_hidden'
defaultMessage='Media hidden'
/>
)}
</strong>
<FormattedMessage
defaultMessage='Click to view'
id='status.sensitive_toggle'
/>
</span>
</CommonButton>
); // No children with hidden media
// If our media is visible, then we render it alongside the
// "eyeball" button.
} else {
button = (
<CommonButton
className='gallery\sensitive gallery\button'
icon={visible ? 'eye' : 'eye-slash'}
title={intl.formatMessage(messages.hide)}
onClick={handleToggle}
/>
);
// If our first item is a video, we render a player. Otherwise,
// we render our images.
if (attachments.getIn([0, 'type']) === 'video') {
size = 1;
children = (
<StatusContentGalleryPlayer
attachment={attachments.get(0)}
autoPlayGif={autoPlayGif}
intl={intl}
letterbox={letterbox}
onClick={handleVideoClick}
/>
);
} else {
size = useableAttachments.size;
children = useableAttachments.map(
(attachment, index) => (
<StatusContentGalleryItem
attachment={attachment}
autoPlayGif={autoPlayGif}
gallerySize={size}
index={index}
intl={intl}
key={attachment.get('id')}
letterbox={letterbox}
onClick={handleMediaClick}
/>
)
);
}
}
// Renders the gallery.
return (
<div
className={computedClass}
style={{ height: `${this.props.height}px` }}
>
{button}
{children}
</div>
);
}
}

View File

@@ -0,0 +1,141 @@
// <StatusContentGalleryItem>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/item
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
expand: { id: 'media_gallery.expand', defaultMessage: 'Expand image' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryItem extends ImmutablePureComponent {
// Props.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
gallerySize: PropTypes.number.isRequired,
index: PropTypes.number.isRequired,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
};
// Click handling.
handleClick = this.props.onClick.bind(this, this.props.index);
// Item rendering.
render () {
const { handleClick } = this;
const {
attachment,
autoPlayGif,
gallerySize,
intl,
letterbox,
} = this.props;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let thumbnail = '';
const computedClass = classNames('glitch', 'glitch__status__content__gallery__item', {
_letterbox: letterbox,
});
// If our gallery has more than one item, our images only take up
// half the width. We need this for image `sizes` calculations.
let multiplier = gallerySize === 1 ? 1 : .5;
// Image attachments
if (attachment.get('type') === 'image') {
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
// This lets the browser conditionally select the preview or
// original image depending on what the rendered size ends up
// being. We, of course, have no way of knowing what the width
// of the gallery will be postCSS, but we conservatively roll
// with 400px. (Note: Upstream Mastodon used media queries here,
// but because our page layout is user-configurable, we don't
// bother.)
const srcSet = `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w`;
const sizes = `${(400 * multiplier) >> 0}px`;
// The image.
thumbnail = (
<img
alt=''
className='item\image'
sizes={sizes}
src={previewUrl}
srcSet={srcSet}
/>
);
// Gifv attachments.
} else if (attachment.get('type') === 'gifv') {
const autoPlay = !isIOS() && autoPlayGif;
thumbnail = (
<video
autoPlay={autoPlay}
className='item\gifv'
loop
muted
poster={previewUrl}
src={originalUrl}
/>
);
}
// Rendering. We render the item inside of a button+link, which
// provides the original. (We can do this for gifvs because we
// don't show the controls.)
return (
<CommonButton
className={computedClass}
data-gallery-size={gallerySize}
href={remoteUrl || originalUrl}
key={attachment.get('id')}
onClick={handleClick}
title={intl.formatMessage(messages.expand)}
>{thumbnail}</CommonButton>
);
}
}

View File

@@ -0,0 +1,88 @@
@import 'variables';
.glitch.glitch__status__content__gallery__item {
display: inline-block;
position: relative;
width: 100%;
height: 100%;
cursor: zoom-in;
.item\\image,
.item\\gifv {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
&.letterbox {
.item\\image,
.item\\gifv {
width: auto;
height: auto;
object-fit: fill;
}
}
&[data-gallery-size="2"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:last-child {
margin: .375em .375em .375em .1875em;
}
}
&[data-gallery-size="3"] {
width: calc(50% - .5625em);
height: calc(100% - .75em);
margin: .375em .1875em .375em .375em;
&:nth-last-child(2) {
float: right;
height: calc(50% - .5625em);
margin: .375em .375em .1875em .1875em;
}
&:last-child {
float: right;
height: calc(50% - .5625em);
margin: .1875em .375em .1875em .375em;
}
}
&[data-gallery-size="4"] {
width: calc(50% - .5625em);
height: calc(50% - .5625em);
margin: .375em .1875em .1875em .375em;
&:nth-last-child(3) {
margin: .375em .375em .1875em .1875em;
}
&:nth-last-child(2) {
margin: .1875em .1875em .375em .375em;
}
&:last-child {
margin: .1875em .375em .375em .1875em;
}
}
}
// add GIF label in CSS

View File

@@ -0,0 +1,233 @@
// <StatusContentGalleryPlayer>
// ==============
// For code documentation, please see:
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/gallery/player
// For more information, please contact:
// @kibi@glitch.social
// * * * * * * * //
// Imports:
// --------
// Package imports.
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
// Mastodon imports.
import { isIOS } from 'mastodon/is_mobile';
// Our imports.
import CommonButton from 'glitch/components/common/button';
// Stylesheet imports.
import './style';
// * * * * * * * //
// Initial setup
// -------------
// Holds our localization messages.
const messages = defineMessages({
mute: { id: 'video_player.toggle_sound', defaultMessage: 'Toggle sound' },
open: { id: 'video_player.open', defaultMessage: 'Open video' },
play: { id: 'video_player.play', defaultMessage: 'Play video' },
pause: { id: 'video_player.pause', defaultMessage: 'Pause video' },
expand: { id: 'video_player.expand', defaultMessage: 'Expand video' },
});
// * * * * * * * //
// The component
// -------------
export default class StatusContentGalleryPlayer extends ImmutablePureComponent {
// Props and state.
static propTypes = {
attachment: ImmutablePropTypes.map.isRequired,
autoPlayGif: PropTypes.bool,
intl: PropTypes.object.isRequired,
letterbox: PropTypes.bool,
onClick: PropTypes.func.isRequired,
}
state = {
hasAudio: true,
muted: true,
preview: !isIOS() && this.props.autoPlayGif,
videoError: false,
}
// Basic video controls.
handleMute = () => {
this.setState({ muted: !this.state.muted });
}
handlePlayPause = () => {
const { video } = this;
if (video.paused) {
video.play();
} else {
video.pause();
}
}
// When clicking we either open (de-preview) the video or we
// expand it, depending. Note that when we de-preview the video will
// also begin playing (except on iOS) due to its `autoplay`
// attribute.
handleClick = () => {
const { setState, video } = this;
const { onClick } = this.props;
const { preview } = this.state;
if (preview) setState({ preview: false });
else {
video.pause();
onClick(video.currentTime);
}
}
// Loading and errors. We have to do some hacks in order to check if
// the video has audio imo. There's probably a better way to do this
// but that's how upstream has it.
handleLoadedData = () => {
const { video } = this;
if (('WebkitAppearance' in document.documentElement.style && video.audioTracks.length === 0) || video.mozHasAudio === false) {
this.setState({ hasAudio: false });
}
}
handleVideoError = () => {
this.setState({ videoError: true });
}
// On mounting or update, we ensure our video has the needed event
// listeners. We can't necessarily do this right away because there
// might be a preview up.
componentDidMount () {
this.componentDidUpdate();
}
componentDidUpdate () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.addEventListener('loadeddata', handleLoadedData);
video.addEventListener('error', handleVideoError);
}
// On unmounting, we remove the listeners from the video element.
componentWillUnmount () {
const { handleLoadedData, handleVideoError, video } = this;
if (!video) return;
video.removeEventListener('loadeddata', handleLoadedData);
video.removeEventListener('error', handleVideoError);
}
// Getting a reference to our video.
setRef = (c) => {
this.video = c;
}
// Rendering.
render () {
const {
handleClick,
handleMute,
handlePlayPause,
setRef,
video,
} = this;
const {
attachment,
letterbox,
intl,
} = this.props;
const {
hasAudio,
muted,
preview,
videoError,
} = this.state;
const originalUrl = attachment.get('url');
const previewUrl = attachment.get('preview_url');
const remoteUrl = attachment.get('remote_url');
let content = null;
const computedClass = classNames('glitch', 'glitch__status__content__gallery__player', {
_letterbox: letterbox,
});
// This gets our content: either a preview image, an error
// message, or the video.
switch (true) {
case preview:
content = (
<img
alt=''
className='player\preview'
src={previewUrl}
/>
);
break;
case videoError:
content = (
<span className='player\error'>
<FormattedMessage id='video_player.video_error' defaultMessage='Video could not be played' />
</span>
);
break;
default:
content = (
<video
autoPlay={!isIOS()}
className='player\video'
loop
muted={muted}
poster={previewUrl}
ref={setRef}
src={originalUrl}
/>
);
break;
}
// Everything goes inside of a button because everything is a
// button. This is okay wrt the video element because it doesn't
// have controls.
return (
<div className={computedClass}>
<CommonButton
className='player\box'
href={remoteUrl || originalUrl}
key='box'
onClick={handleClick}
title={intl.formatMessage(preview ? messages.open : messages.expand)}
>{content}</CommonButton>
{!preview ? (
<CommonButton
active={!video.paused}
className='player\play-pause player\button'
icon={video.paused ? 'play' : 'pause'}
key='play'
onClick={handlePlayPause}
title={intl.formatMessage(messages.play)}
/>
) : null}
{!preview && hasAudio ? (
<CommonButton
active={!muted}
className='player\mute player\button'
icon={muted ? 'volume-off' : 'volume-up'}
key='mute'
onClick={handleMute}
title={intl.formatMessage(messages.mute)}
/>
) : null}
</div>
);
}
}

View File

@@ -0,0 +1,71 @@
@import 'variables';
.glitch.glitch__status__content__gallery__player {
display: block;
padding: (1.5em * 1.35) 0; // Creates black bars at the bottom/top
width: 100%;
height: calc(100% - (1.5em * 1.35 * 2));
cursor: zoom-in;
.player\\box {
display: block;
position: relative;
width: 100%;
height: 100%;
& > img,
& > video {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: auto;
max-width: 100%;
height: auto;
max-height: 100%;
@supports (object-fit: cover) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
.player\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&.player\\play-pause {
bottom: 0;
left: 0;
}
&.player\\mute {
bottom: 0;
right: 0;
}
}
&._letterbox {
.player\\box {
& > img,
& > video {
width: auto;
height: auto;
object-fit: fill;
}
}
}
}

View File

@@ -0,0 +1,74 @@
@import 'variables';
.glitch.glitch__status__content__gallery {
display: block;
position: relative;
color: $ui-primary-color;
background: $base-shadow-color;
.gallery\\button {
position: absolute;
margin: .35em;
border-radius: .35em;
padding: .1625em;
height: 1em; // 1 + 2*.35 + 2*.1625 = 1.5*1.35
color: $primary-text-color;
background: $base-overlay-background;
font-size: 1em;
line-height: 1;
opacity: .7;
&:hover {
opacity: 1;
}
&.gallery\\sensitive {
top: 0;
left: 0;
}
}
.gallery\\curtain.gallery\\sensitive {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 0;
padding: 0;
color: $ui-secondary-color;
background: $base-overlay-background;
font-size: (1.25em / 1.35); // approx. .925em
line-height: 1.35;
text-align: center;
white-space: nowrap;
cursor: pointer;
transition: color ($glitch-animation-speed * .15s) ease-in;
.gallery\\message {
display: block;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 2.6em;
margin: auto;
.gallery\\warning {
display: block;
font-size: (1.35em / 1.25);
line-height: 1.35;
}
}
&:active,
&:focus,
&:hover {
color: $primary-text-color;
background: $base-overlay-background; // No change
transition: color ($glitch-animation-speed * .3s) ease-out;
}
}
}