mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-24 19:37:26 +00:00
[WIP] Initial status work
This commit is contained in:
190
app/javascript/glitch/components/status/content/card/index.js
Normal file
190
app/javascript/glitch/components/status/content/card/index.js
Normal file
@@ -0,0 +1,190 @@
|
||||
// <StatusContentCard>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/card
|
||||
|
||||
// For more information, please contact:
|
||||
// @kibi@glitch.social
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Imports
|
||||
// -------
|
||||
|
||||
// Package imports.
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import punycode from 'punycode';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// Mastodon imports.
|
||||
import emojify from 'mastodon/emoji';
|
||||
|
||||
// Our imports.
|
||||
import CommonLink from 'glitch/components/common/link';
|
||||
import CommonSeparator from 'glitch/components/common/separator';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Initial setup
|
||||
// -------------
|
||||
|
||||
// Reliably gets the hostname from a URL.
|
||||
const getHostname = url => {
|
||||
const parser = document.createElement('a');
|
||||
parser.href = url;
|
||||
return parser.hostname;
|
||||
};
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
export default class Card extends ImmutablePureComponent {
|
||||
|
||||
// Props.
|
||||
static propTypes = {
|
||||
card: ImmutablePropTypes.map.isRequired,
|
||||
fullwidth: PropTypes.bool,
|
||||
letterbox: PropTypes.bool,
|
||||
}
|
||||
|
||||
// Rendering.
|
||||
render () {
|
||||
const { card, fullwidth, letterbox } = this.props;
|
||||
let media = null;
|
||||
let text = null;
|
||||
let author = null;
|
||||
let provider = null;
|
||||
let caption = null;
|
||||
|
||||
// This gets all of our card properties.
|
||||
const authorName = card.get('author_name');
|
||||
const authorUrl = card.get('author_url');
|
||||
const description = card.get('description');
|
||||
const html = card.get('html');
|
||||
const image = card.get('image');
|
||||
const providerName = card.get('provider_name');
|
||||
const providerUrl = card.get('provider_url');
|
||||
const title = card.get('title');
|
||||
const type = card.get('type');
|
||||
const url = card.get('url');
|
||||
|
||||
// Sets our class.
|
||||
const computedClass = classNames('glitch', 'glitch__status__content__card', type, {
|
||||
_fullwidth: fullwidth,
|
||||
_letterbox: letterbox,
|
||||
});
|
||||
|
||||
// A card is required to render.
|
||||
if (!card) return null;
|
||||
|
||||
// This generates our card media (image or video).
|
||||
switch(type) {
|
||||
case 'photo':
|
||||
media = (
|
||||
<CommonLink
|
||||
className='card\media card\photo'
|
||||
href={url}
|
||||
>
|
||||
<img
|
||||
alt={title}
|
||||
src={image}
|
||||
/>
|
||||
</CommonLink>
|
||||
);
|
||||
break;
|
||||
case 'video':
|
||||
media = (
|
||||
<div
|
||||
className='card\media card\video'
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// If we have at least a title or a description, then we can
|
||||
// render some textual contents.
|
||||
if (title || description) {
|
||||
text = (
|
||||
<CommonLink
|
||||
className='card\description'
|
||||
href={url}
|
||||
>
|
||||
{type === 'link' && image ? (
|
||||
<div className='card\thumbnail'>
|
||||
<img
|
||||
alt=''
|
||||
className='card\image'
|
||||
src={image}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{title ? (
|
||||
<h1 className='card\title'>{title}</h1>
|
||||
) : null}
|
||||
{emojify(description)}
|
||||
</CommonLink>
|
||||
);
|
||||
}
|
||||
|
||||
// This creates links or spans (depending on whether a URL was
|
||||
// provided) for the card author and provider.
|
||||
if (authorUrl) {
|
||||
author = (
|
||||
<CommonLink
|
||||
className='card\author card\link'
|
||||
href={authorUrl}
|
||||
>
|
||||
{authorName ? authorName : punycode.toUnicode(getHostname(authorUrl))}
|
||||
</CommonLink>
|
||||
);
|
||||
} else if (authorName) {
|
||||
author = <span className='card\author'>{authorName}</span>;
|
||||
}
|
||||
if (providerUrl) {
|
||||
provider = (
|
||||
<CommonLink
|
||||
className='card\provider card\link'
|
||||
href={providerUrl}
|
||||
>
|
||||
{providerName ? providerName : punycode.toUnicode(getHostname(providerUrl))}
|
||||
</CommonLink>
|
||||
);
|
||||
} else if (providerName) {
|
||||
provider = <span className='card\provider'>{providerName}</span>;
|
||||
}
|
||||
|
||||
// If we have either the author or the provider, then we can
|
||||
// render an attachment.
|
||||
if (author || provider) {
|
||||
caption = (
|
||||
<figcaption className='card\caption'>
|
||||
{author}
|
||||
<CommonSeparator
|
||||
className='card\separator'
|
||||
visible={author && provider}
|
||||
/>
|
||||
{provider}
|
||||
</figcaption>
|
||||
);
|
||||
}
|
||||
|
||||
// Putting the pieces together and returning.
|
||||
return (
|
||||
<figure className={computedClass}>
|
||||
{media}
|
||||
{text}
|
||||
{caption}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
123
app/javascript/glitch/components/status/content/card/style.scss
Normal file
123
app/javascript/glitch/components/status/content/card/style.scss
Normal file
@@ -0,0 +1,123 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__content__card {
|
||||
display: block;
|
||||
border: thin $glitch-texture-color solid;
|
||||
border-radius: .35em;
|
||||
background: $glitch-darker-color;
|
||||
|
||||
.card\\caption {
|
||||
color: $ui-primary-color;
|
||||
background: $glitch-texture-color;
|
||||
font-size: (1.25em / 1.35); // approx. .925em
|
||||
|
||||
.card\\link { // caption links
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card\\media {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 13.5em;
|
||||
|
||||
/*
|
||||
Our fallback styles letterbox the media, but we'll expand it to
|
||||
fill the container if supported. This won't do anything for
|
||||
`<iframe>`s, but we'll just have to trust them to manage their
|
||||
own content.
|
||||
*/
|
||||
& > * {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card\\description {
|
||||
color: $ui-secondary-color;
|
||||
background: $ui-base-color;
|
||||
|
||||
.card\\thumbnail {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 6.75em;
|
||||
height: 100%;
|
||||
background: $glitch-darker-color;
|
||||
|
||||
& > img {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
We have to divide the bottom margin of titles by their font-size to
|
||||
get them to match what we use elsewhere.
|
||||
*/
|
||||
.card\\title {
|
||||
margin-bottom: (.75em * 1.35 / 1.5);
|
||||
font-size: 1.5em;
|
||||
line-height: 1.125; // = 1.35 * (1.25 / 1.5)
|
||||
}
|
||||
}
|
||||
|
||||
&._fullwidth {
|
||||
margin-left: -.75em;
|
||||
width: calc(100% + 1.5em);
|
||||
}
|
||||
|
||||
/*
|
||||
If `letterbox` is specified, then we don't need object-fit (since
|
||||
we essentially just do a scale-down).
|
||||
*/
|
||||
&._letterbox {
|
||||
.card\\description .card\\thumbnail {
|
||||
& > img {
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: fill;
|
||||
}
|
||||
}
|
||||
|
||||
.card\\media {
|
||||
& > * {
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: fill;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
app/javascript/glitch/components/status/content/gallery/index.js
Normal file
191
app/javascript/glitch/components/status/content/gallery/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 post–CSS, 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
520
app/javascript/glitch/components/status/content/index.js
Normal file
520
app/javascript/glitch/components/status/content/index.js
Normal file
@@ -0,0 +1,520 @@
|
||||
// <StatusContent>
|
||||
// ===============
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/content
|
||||
|
||||
// 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 { isRtl } from 'mastodon/rtl';
|
||||
|
||||
// Our imports.
|
||||
import StatusContentCard from './card';
|
||||
import StatusContentGallery from './gallery';
|
||||
import StatusContentUnknown from './unknown';
|
||||
import CommonButton from 'glitch/components/common/button';
|
||||
import CommonLink from 'glitch/components/common/link';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// Initial setup
|
||||
// -------------
|
||||
|
||||
// Holds our localization messages.
|
||||
const messages = defineMessages({
|
||||
card_link :
|
||||
{ id: 'status.card', defaultMessage: 'Card' },
|
||||
video_link :
|
||||
{ id: 'status.video', defaultMessage: 'Video' },
|
||||
image_link :
|
||||
{ id: 'status.image', defaultMessage: 'Image' },
|
||||
unknown_link :
|
||||
{ id: 'status.unknown_attachment', defaultMessage: 'Unknown attachment' },
|
||||
hashtag :
|
||||
{ id: 'status.hashtag', defaultMessage: 'Hashtag @{name}' },
|
||||
show_more :
|
||||
{ id: 'status.show_more', defaultMessage: 'Show more' },
|
||||
show_less :
|
||||
{ id: 'status.show_less', defaultMessage: 'Show less' },
|
||||
});
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
|
||||
export default class StatusContent extends ImmutablePureComponent {
|
||||
|
||||
// Props and state.
|
||||
static propTypes = {
|
||||
autoPlayGif: PropTypes.bool,
|
||||
detailed: PropTypes.bool,
|
||||
expanded: PropTypes.oneOf([true, false, null]),
|
||||
handler: PropTypes.object.isRequired,
|
||||
hideMedia: PropTypes.bool,
|
||||
history: PropTypes.object,
|
||||
intl: PropTypes.object.isRequired,
|
||||
letterbox: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
onHeightUpdate: PropTypes.func,
|
||||
setExpansion: PropTypes.func,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
}
|
||||
state = {
|
||||
hidden: true,
|
||||
}
|
||||
|
||||
// Variables.
|
||||
text = null
|
||||
|
||||
// Our constructor preprocesses our status content and turns it into
|
||||
// an array of React elements, stored in `this.text`.
|
||||
constructor (props) {
|
||||
super(props);
|
||||
const { intl, history, status } = props;
|
||||
|
||||
// This creates a document fragment with the DOM contents of our
|
||||
// status's text and a TreeWalker to walk them.
|
||||
const range = document.createRange();
|
||||
range.selectNode(document.body);
|
||||
const walker = document.createTreeWalker(
|
||||
range.createContextualFragment(status.get('contentHtml')),
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
{ acceptNode (node) {
|
||||
const name = node.nodeName;
|
||||
switch (true) {
|
||||
case node.parentElement && node.parentElement.nodeName.toUpperCase() === 'A':
|
||||
return NodeFilter.FILTER_REJECT; // No link children
|
||||
case node.nodeType === Node.TEXT_NODE:
|
||||
case name.toUpperCase() === 'A':
|
||||
case name.toUpperCase() === 'P':
|
||||
case name.toUpperCase() === 'BR':
|
||||
case name.toUpperCase() === 'IMG': // Emoji
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
default:
|
||||
return NodeFilter.FILTER_SKIP;
|
||||
}
|
||||
} },
|
||||
);
|
||||
const attachments = status.get('attachments');
|
||||
const card = (!attachments || !attachments.size) && status.get('card');
|
||||
this.text = [];
|
||||
let currentP = [];
|
||||
|
||||
// This walks the contents of our status.
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
const nodeName = node.nodeName.toUpperCase();
|
||||
switch (nodeName) {
|
||||
|
||||
// If our element is a link, then we process it here.
|
||||
case 'A':
|
||||
currentP.push((() => {
|
||||
|
||||
// Here we detect what kind of link we're dealing with.
|
||||
let mention = status.get('mentions') ? status.get('mentions').find(
|
||||
item => node.href === item.get('url')
|
||||
) : null;
|
||||
let tag = status.get('tags') ? status.get('tags').find(
|
||||
item => node.href === item.get('url')
|
||||
) : null;
|
||||
let attachment = attachments ? attachments.find(
|
||||
item => node.href === item.get('url') || node.href === item.get('text_url') || node.href === item.get('remote_url')
|
||||
) : null;
|
||||
let text = node.textContent;
|
||||
let icon = '';
|
||||
let type = '';
|
||||
|
||||
// We use a switch to select our link type.
|
||||
switch (true) {
|
||||
|
||||
// This handles cards.
|
||||
case card && node.href === card.get('url'):
|
||||
text = card.get('title') || intl.formatMessage(messages.card);
|
||||
icon = 'id-card-o';
|
||||
return (
|
||||
<CommonButton
|
||||
className={'content\card content\button'}
|
||||
href={node.href}
|
||||
icon={icon}
|
||||
key={currentP.length}
|
||||
showTitle
|
||||
title={text}
|
||||
/>
|
||||
);
|
||||
|
||||
// This handles mentions.
|
||||
case mention && (text.replace(/^@/, '') === mention.get('username') || text.replace(/^@/, '') === mention.get('acct')):
|
||||
icon = text[0] === '@' ? '@' : '';
|
||||
text = mention.get('acct').split('@');
|
||||
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
|
||||
return (
|
||||
<CommonLink
|
||||
className='content\mention content\link'
|
||||
destination={`/accounts/${mention.get('id')}`}
|
||||
history={history}
|
||||
href={node.href}
|
||||
key={currentP.length}
|
||||
title={'@' + mention.get('acct')}
|
||||
>
|
||||
{icon ? <span className='content\at'>{icon}</span> : null}
|
||||
<span className='content\username'>{text[0]}</span>
|
||||
{text[1] ? <span className='content\at'>@</span> : null}
|
||||
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
|
||||
</CommonLink>
|
||||
);
|
||||
|
||||
// This handles attachment links.
|
||||
case !!attachment:
|
||||
type = attachment.get('type');
|
||||
switch (type) {
|
||||
case 'unknown':
|
||||
text = intl.formatMessage(messages.unknown_attachment);
|
||||
icon = 'question';
|
||||
break;
|
||||
case 'video':
|
||||
text = intl.formatMessage(messages.video);
|
||||
icon = 'video-camera';
|
||||
break;
|
||||
default:
|
||||
text = intl.formatMessage(messages.image);
|
||||
icon = 'picture-o';
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<CommonButton
|
||||
className={`content\\${type} content\\button`}
|
||||
href={node.href}
|
||||
icon={icon}
|
||||
key={currentP.length}
|
||||
showTitle
|
||||
title={text}
|
||||
/>
|
||||
);
|
||||
|
||||
// This handles hashtag links.
|
||||
case !!tag && (text.replace(/^#/, '') === tag.get('name')):
|
||||
icon = text[0] === '#' ? '#' : '';
|
||||
text = tag.get('name');
|
||||
return (
|
||||
<CommonLink
|
||||
className='content\tag content\link'
|
||||
destination={`/timelines/tag/${tag.get('name')}`}
|
||||
history={history}
|
||||
href={node.href}
|
||||
key={currentP.length}
|
||||
title={intl.formatMessage(messages.hashtag, { name: tag.get('name') })}
|
||||
>
|
||||
{icon ? <span className='content\hash'>{icon}</span> : null}
|
||||
<span className='content\tagname'>{text}</span>
|
||||
</CommonLink>
|
||||
);
|
||||
|
||||
// This handles all other links.
|
||||
default:
|
||||
if (text === node.href && text.length > 23) {
|
||||
text = text.substr(0, 22) + '…';
|
||||
}
|
||||
return (
|
||||
<CommonLink
|
||||
className='content\link'
|
||||
href={node.href}
|
||||
key={currentP.length}
|
||||
title={node.href}
|
||||
>{text}</CommonLink>
|
||||
);
|
||||
}
|
||||
})());
|
||||
break;
|
||||
|
||||
// If our element is an IMG, we only render it if it's an emoji.
|
||||
case 'IMG':
|
||||
if (!node.classList.contains('emojione')) break;
|
||||
currentP.push(
|
||||
<img
|
||||
alt={node.alt}
|
||||
className={'content\emojione'}
|
||||
draggable={false}
|
||||
key={currentP.length}
|
||||
src={node.src}
|
||||
{...(node.title ? { title: node.title } : {})}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
// If our element is a BR, we pass it along.
|
||||
case 'BR':
|
||||
currentP.push(<br key={currentP.length} />);
|
||||
break;
|
||||
|
||||
// If our element is a P, then we need to start a new paragraph.
|
||||
// If our paragraph has content, we need to push it first.
|
||||
case 'P':
|
||||
if (currentP.length) this.text.push(
|
||||
<p key={this.text.length}>
|
||||
{currentP}
|
||||
</p>
|
||||
);
|
||||
currentP = [];
|
||||
break;
|
||||
|
||||
// Otherwise we just push the text.
|
||||
default:
|
||||
currentP.push(node.textContent);
|
||||
}
|
||||
}
|
||||
|
||||
// If there is unpushed paragraph content after walking the entire
|
||||
// status contents, we push it here.
|
||||
if (currentP.length) this.text.push(
|
||||
<p key={this.text.length}>
|
||||
{currentP}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// When our content changes, we need to update the height of the
|
||||
// status.
|
||||
componentDidUpdate () {
|
||||
if (this.props.onHeightUpdate) {
|
||||
this.props.onHeightUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// When the mouse is pressed down, we grab its position.
|
||||
handleMouseDown = (e) => {
|
||||
this.startXY = [e.clientX, e.clientY];
|
||||
}
|
||||
|
||||
// When the mouse is raised, we handle the click if it wasn't a part
|
||||
// of a drag.
|
||||
handleMouseUp = (e) => {
|
||||
const { startXY } = this;
|
||||
const { onClick } = this.props;
|
||||
const { button, clientX, clientY, target } = e;
|
||||
|
||||
// This gets the change in mouse position. If `startXY` isn't set,
|
||||
// it means that the click originated elsewhere.
|
||||
if (!startXY) return;
|
||||
const [ deltaX, deltaY ] = [clientX - startXY[0], clientY - startXY[1]];
|
||||
|
||||
// This switch prevents an overly lengthy if.
|
||||
switch (true) {
|
||||
|
||||
// If the button being released isn't the main mouse button, or if
|
||||
// we don't have a click parsing function, or if the mouse has
|
||||
// moved more than 5px, OR if the target of the mouse event is a
|
||||
// button or a link, we do nothing.
|
||||
case button !== 0:
|
||||
case !onClick:
|
||||
case Math.sqrt(deltaX ** 2 + deltaY ** 2) >= 5:
|
||||
case (
|
||||
target.matches || target.msMatchesSelector || target.webkitMatchesSelector || (() => void 0)
|
||||
).call(target, 'button, button *, a, a *'):
|
||||
break;
|
||||
|
||||
// Otherwise, we parse the click.
|
||||
default:
|
||||
onClick(e);
|
||||
break;
|
||||
}
|
||||
|
||||
// This resets our mouse location.
|
||||
this.startXY = null;
|
||||
}
|
||||
|
||||
// This expands and collapses our spoiler.
|
||||
handleSpoilerClick = (e) => {
|
||||
e.preventDefault();
|
||||
if (this.props.setExpansion) {
|
||||
this.props.setExpansion(this.props.expanded ? null : true);
|
||||
} else {
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
}
|
||||
|
||||
// Renders our component.
|
||||
render () {
|
||||
const {
|
||||
handleMouseDown,
|
||||
handleMouseUp,
|
||||
handleSpoilerClick,
|
||||
text,
|
||||
} = this;
|
||||
const {
|
||||
autoPlayGif,
|
||||
detailed,
|
||||
expanded,
|
||||
handler,
|
||||
hideMedia,
|
||||
intl,
|
||||
letterbox,
|
||||
onClick,
|
||||
setExpansion,
|
||||
status,
|
||||
} = this.props;
|
||||
const attachments = status.get('attachments');
|
||||
const card = status.get('card');
|
||||
const hidden = setExpansion ? !expanded : this.state.hidden;
|
||||
const computedClass = classNames('glitch', 'glitch__status__content', {
|
||||
_actionable: !detailed && onClick,
|
||||
_rtl: isRtl(status.get('search_index')),
|
||||
});
|
||||
let media = null;
|
||||
let mediaIcon = '';
|
||||
|
||||
// This defines our media.
|
||||
if (!hideMedia) {
|
||||
|
||||
// If there aren't any attachments, we try showing a card.
|
||||
if ((!attachments || !attachments.size) && card) {
|
||||
media = (
|
||||
<StatusContentCard
|
||||
card={card}
|
||||
className='content\attachments content\card'
|
||||
fullwidth={detailed}
|
||||
letterbox={letterbox}
|
||||
/>
|
||||
);
|
||||
mediaIcon = 'id-card-o';
|
||||
|
||||
// If any of the attachments are of unknown type, we render an
|
||||
// unknown attachments list.
|
||||
} else if (attachments && attachments.some(
|
||||
(item) => item.get('type') === 'unknown'
|
||||
)) {
|
||||
media = (
|
||||
<StatusContentUnknown
|
||||
attachments={attachments}
|
||||
className='content\attachments content\unknown'
|
||||
fullwidth={detailed}
|
||||
/>
|
||||
);
|
||||
mediaIcon = 'question';
|
||||
|
||||
// Otherwise, we display the gallery.
|
||||
} else if (attachments) {
|
||||
media = (
|
||||
<StatusContentGallery
|
||||
attachments={attachments}
|
||||
autoPlayGif={autoPlayGif}
|
||||
className='content\attachments content\gallery'
|
||||
fullwidth={detailed}
|
||||
intl={intl}
|
||||
letterbox={letterbox}
|
||||
onOpenMedia={handler.openMedia}
|
||||
onOpenVideo={handler.openVideo}
|
||||
sensitive={status.get('sensitive')}
|
||||
standalone={!history}
|
||||
/>
|
||||
);
|
||||
mediaIcon = attachments.getIn([0, 'type']) === 'video' ? 'film' : 'picture-o';
|
||||
}
|
||||
}
|
||||
|
||||
// Spoiler stuff.
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
|
||||
// This gets our list of mentions.
|
||||
const mentionLinks = status.get('mentions').map(mention => {
|
||||
const text = mention.get('acct').split('@');
|
||||
if (text[1]) text[1].replace(/[@.][^.]*/g, (m) => m.substr(0, 2));
|
||||
return (
|
||||
<CommonLink
|
||||
className='content\mention content\link'
|
||||
destination={`/accounts/${mention.get('id')}`}
|
||||
history={history}
|
||||
href={mention.get('url')}
|
||||
key={mention.get('id')}
|
||||
title={'@' + mention.get('acct')}
|
||||
>
|
||||
<span className='content\at'>@</span>
|
||||
<span className='content\username'>{text[0]}</span>
|
||||
{text[1] ? <span className='content\at'>@</span> : null}
|
||||
{text[1] ? <span className='content\instance'>{text[1]}</span> : null}
|
||||
</CommonLink>
|
||||
);
|
||||
}).reduce((aggregate, item) => [...aggregate, ' ', item], []);
|
||||
|
||||
// Component rendering.
|
||||
return (
|
||||
<div className={computedClass}>
|
||||
<div
|
||||
className='content\spoiler'
|
||||
{...(onClick ? {
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
} : {})}
|
||||
>
|
||||
<p>
|
||||
<span
|
||||
className='content\warning'
|
||||
dangerouslySetInnerHTML={status.get('spoilerHtml')}
|
||||
/>
|
||||
{' '}
|
||||
<CommonButton
|
||||
active={!hidden}
|
||||
className='content\showmore'
|
||||
icon={hidden && mediaIcon}
|
||||
onClick={handleSpoilerClick}
|
||||
showTitle={hidden}
|
||||
title={intl.formatMessage(messages.show_more)}
|
||||
>
|
||||
{hidden ? null : (
|
||||
<FormattedMessage {...messages.show_less} />
|
||||
)}
|
||||
</CommonButton>
|
||||
</p>
|
||||
</div>
|
||||
{hidden ? mentionLinks : null}
|
||||
<div className='content\contents' hidden={hidden}>
|
||||
<div
|
||||
className='content\text'
|
||||
{...(onClick ? {
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
} : {})}
|
||||
>{text}</div>
|
||||
{media}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Non-spoiler statuses.
|
||||
} else {
|
||||
return (
|
||||
<div className={computedClass}>
|
||||
<div className='content\contents'>
|
||||
<div
|
||||
className='content\text'
|
||||
{...(onClick ? {
|
||||
onMouseDown: handleMouseDown,
|
||||
onMouseUp: handleMouseUp,
|
||||
} : {})}
|
||||
>{text}</div>
|
||||
{media}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
101
app/javascript/glitch/components/status/content/style.scss
Normal file
101
app/javascript/glitch/components/status/content/style.scss
Normal file
@@ -0,0 +1,101 @@
|
||||
@import 'variables';
|
||||
|
||||
.glitch.glitch__status__content {
|
||||
position: relative;
|
||||
padding: (.75em * 1.35) .75em;
|
||||
color: $primary-text-color;
|
||||
direction: ltr; // but see `&.rtl` below
|
||||
word-wrap: break-word;
|
||||
overflow: visible;
|
||||
white-space: pre-wrap;
|
||||
|
||||
.content\\contents {
|
||||
.content\\attachments {
|
||||
.content\\text + & {
|
||||
margin-top: (.75em * 1.35);
|
||||
}
|
||||
}
|
||||
|
||||
&[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content\\spoiler + & {
|
||||
margin-top: (.75em * 1.35);
|
||||
}
|
||||
}
|
||||
|
||||
.content\\emojione {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
.content\\spoiler,
|
||||
.content\\text { // text-containing elements
|
||||
p {
|
||||
margin-bottom: (.75em * 1.35);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content\\link {
|
||||
color: $ui-secondary-color;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
For mentions, we only underline the username and instance (not
|
||||
the @'s).
|
||||
*/
|
||||
&.content\\mention {
|
||||
.content\\at {
|
||||
color: $glitch-lighter-color;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.content\\instance,
|
||||
.content\\username {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Similarly, for tags, we only underline the tag name (not the
|
||||
hash).
|
||||
*/
|
||||
&.content\\tag {
|
||||
.content\\hash {
|
||||
color: $glitch-lighter-color;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.content\\tagname {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&._actionable {
|
||||
.content\\text,
|
||||
.content\\spoiler {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&._rtl {
|
||||
direction: rtl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// <StatusContentUnknown>
|
||||
// ========
|
||||
|
||||
// For code documentation, please see:
|
||||
// https://glitch-soc.github.io/docs/javascript/glitch/status/content/unknown
|
||||
|
||||
// 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';
|
||||
|
||||
// Our imports.
|
||||
import CommonIcon from 'glitch/components/common/icon';
|
||||
import CommonLink from 'glitch/components/common/link';
|
||||
|
||||
// Stylesheet imports.
|
||||
import './style';
|
||||
|
||||
// * * * * * * * //
|
||||
|
||||
// The component
|
||||
// -------------
|
||||
export default class StatusContentUnknown extends ImmutablePureComponent {
|
||||
|
||||
// Props.
|
||||
static propTypes = {
|
||||
attachments: ImmutablePropTypes.list.isRequired,
|
||||
fullwidth: PropTypes.bool,
|
||||
}
|
||||
|
||||
render () {
|
||||
const { attachments, fullwidth } = this.props;
|
||||
const computedClass = classNames('glitch', 'glitch__status__content__unknown', {
|
||||
_fullwidth: fullwidth,
|
||||
});
|
||||
|
||||
return (
|
||||
<ul className={computedClass}>
|
||||
{attachments.map(attachment => (
|
||||
<li
|
||||
className='unknown\attachment'
|
||||
key={attachment.get('id')}
|
||||
>
|
||||
<CommonLink
|
||||
className='unknown\link'
|
||||
href={attachment.get('remote_url')}
|
||||
>
|
||||
<CommonIcon
|
||||
className='unknown\icon'
|
||||
name='link'
|
||||
/>
|
||||
{attachment.get('title') || attachment.get('remote_url')}
|
||||
</CommonLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user