Merge branch 'master' into glitch-soc/merge-upstream

This commit is contained in:
Thibaut Girka
2020-06-26 13:02:14 +02:00
52 changed files with 650 additions and 394 deletions

View File

@ -77,6 +77,18 @@ module ApplicationHelper
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
end
def visibility_icon(status)
if status.public_visibility?
fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
elsif status.unlisted_visibility?
fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
elsif status.private_visibility? || status.limited_visibility?
fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
elsif status.direct_visibility?
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
end
end
def custom_emoji_tag(custom_emoji, animate = true)
if animate
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")

View File

@ -15,11 +15,13 @@ module StatusesHelper
end
def media_summary(status)
attachments = { image: 0, video: 0 }
attachments = { image: 0, video: 0, audio: 0 }
status.media_attachments.each do |media|
if media.video?
attachments[:video] += 1
elsif media.audio?
attachments[:audio] += 1
else
attachments[:image] += 1
end

View File

@ -10,7 +10,7 @@ import StatusContent from './status_content';
import StatusActionBar from './status_action_bar';
import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
@ -51,6 +51,13 @@ export const defaultMediaVisibility = (status) => {
return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
};
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
class Status extends ImmutablePureComponent {
@ -416,6 +423,15 @@ class Status extends ImmutablePureComponent {
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
}
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
return (
<HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
@ -425,6 +441,7 @@ class Status extends ImmutablePureComponent {
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>

View File

@ -237,9 +237,6 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account');
let menu = [];
let reblogIcon = 'retweet';
let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
@ -259,10 +256,6 @@ class StatusActionBar extends ImmutablePureComponent {
if (status.getIn(['account', 'id']) === me) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
} else {
if (status.get('visibility') === 'private') {
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
}
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
@ -305,12 +298,8 @@ class StatusActionBar extends ImmutablePureComponent {
}
}
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
let replyIcon;
let replyTitle;
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
@ -319,6 +308,19 @@ class StatusActionBar extends ImmutablePureComponent {
replyTitle = intl.formatMessage(messages.replyAll);
}
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle = '';
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
const shareButton = ('share' in navigator) && publicStatus && (
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
@ -326,7 +328,7 @@ class StatusActionBar extends ImmutablePureComponent {
return (
<div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button' disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton}

View File

@ -154,6 +154,7 @@ class Audio extends React.PureComponent {
width: PropTypes.number,
height: PropTypes.number,
editable: PropTypes.bool,
fullscreen: PropTypes.bool,
intl: PropTypes.object.isRequired,
cacheWidth: PropTypes.func,
};
@ -180,7 +181,7 @@ class Audio extends React.PureComponent {
_setDimensions () {
const width = this.player.offsetWidth;
const height = width / (16/9);
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
@ -291,8 +292,10 @@ class Audio extends React.PureComponent {
}
handleProgress = () => {
if (this.audio.buffered.length > 0) {
this.setState({ buffer: this.audio.buffered.end(0) / this.audio.duration * 100 });
const lastTimeRange = this.audio.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
}
}
@ -349,18 +352,18 @@ class Audio extends React.PureComponent {
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = Math.floor(this.audio.duration * x);
const currentTime = this.audio.duration * x;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}, 60);
}, 15);
handleTimeUpdate = () => {
this.setState({
currentTime: Math.floor(this.audio.currentTime),
currentTime: this.audio.currentTime,
duration: Math.floor(this.audio.duration),
});
}
@ -373,7 +376,7 @@ class Audio extends React.PureComponent {
this.audio.volume = x;
});
}
}, 60);
}, 15);
handleScroll = throttle(() => {
if (!this.canvas || !this.audio) {
@ -451,6 +454,7 @@ class Audio extends React.PureComponent {
_renderCanvas () {
requestAnimationFrame(() => {
this.handleTimeUpdate();
this._clear();
this._draw();
@ -622,7 +626,7 @@ class Audio extends React.PureComponent {
const progress = (currentTime / duration) * 100;
return (
<div className={classNames('audio-player', { editable, 'with-light-background': darkText })} ref={this.setPlayerRef} style={{ width: '100%', height: this.state.height || this.props.height }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className={classNames('audio-player', { editable, 'with-light-background': darkText })} ref={this.setPlayerRef} style={{ width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<audio
src={src}
ref={this.setAudioRef}
@ -630,7 +634,6 @@ class Audio extends React.PureComponent {
onPlay={this.handlePlay}
onPause={this.handlePause}
onProgress={this.handleProgress}
onTimeUpdate={this.handleTimeUpdate}
crossOrigin='anonymous'
/>
@ -691,7 +694,7 @@ class Audio extends React.PureComponent {
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
</span>

View File

@ -7,11 +7,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
const messages = defineMessages({
upload: { id: 'upload_button.label', defaultMessage: 'Add media ({formats})' },
upload: { id: 'upload_button.label', defaultMessage: 'Add images, a video or an audio file' },
});
const SUPPORTED_FORMATS = 'JPEG, PNG, GIF, WebM, MP4, MOV, OGG, WAV, MP3, FLAC';
const makeMapStateToProps = () => {
const mapStateToProps = state => ({
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
@ -60,11 +58,13 @@ class UploadButton extends ImmutablePureComponent {
return null;
}
const message = intl.formatMessage(messages.upload);
return (
<div className='compose-form__upload-button'>
<IconButton icon='paperclip' title={intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<IconButton icon='paperclip' title={message} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
<label>
<span style={{ display: 'none' }}>{intl.formatMessage(messages.upload, { formats: SUPPORTED_FORMATS })}</span>
<span style={{ display: 'none' }}>{message}</span>
<input
key={resetFileKey}
ref={this.setRef}

View File

@ -201,10 +201,6 @@ class ActionBar extends React.PureComponent {
if (me === status.getIn(['account', 'id'])) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
} else {
if (status.get('visibility') === 'private') {
menu.push({ text: intl.formatMessage(status.get('reblogged') ? messages.cancel_reblog_private : messages.reblog_private), action: this.handleReblogClick });
}
}
menu.push(null);
@ -261,14 +257,23 @@ class ActionBar extends React.PureComponent {
replyIcon = 'reply-all';
}
let reblogIcon = 'retweet';
if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
else if (status.get('visibility') === 'private') reblogIcon = 'lock';
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let reblogTitle;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
return (
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus} active={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /></div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
{shareButton}
<div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>

View File

@ -191,7 +191,9 @@ export default class Card extends React.PureComponent {
this.setState({ previewLoaded: true });
}
handleReveal = () => {
handleReveal = e => {
e.preventDefault();
e.stopPropagation();
this.setState({ revealed: true });
}
@ -279,7 +281,7 @@ export default class Card extends React.PureComponent {
}
return (
<div className={className} ref={this.setRef}>
<div className={className} ref={this.setRef} onClick={revealed ? null : this.handleReveal} role={revealed ? 'button' : null}>
{embed}
{!compact && description}
</div>
@ -289,14 +291,12 @@ export default class Card extends React.PureComponent {
<div className='status-card__image'>
{canvas}
{thumbnail}
{!revealed && spoilerButton}
</div>
);
} else {
embed = (
<div className='status-card__image'>
<Icon id='file-text' />
{!revealed && spoilerButton}
</div>
);
}
@ -305,6 +305,7 @@ export default class Card extends React.PureComponent {
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
{embed}
{description}
{!revealed && spoilerButton}
</a>
);
}

View File

@ -6,7 +6,7 @@ import DisplayName from '../../../components/display_name';
import StatusContent from '../../../components/status_content';
import MediaGallery from '../../../components/media_gallery';
import { Link } from 'react-router-dom';
import { FormattedDate } from 'react-intl';
import { injectIntl, defineMessages, FormattedDate } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
@ -16,7 +16,15 @@ import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
export default class DetailedStatus extends ImmutablePureComponent {
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
export default @injectIntl
class DetailedStatus extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
@ -92,7 +100,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
const { compact } = this.props;
const { intl, compact } = this.props;
if (!status) {
return null;
@ -157,34 +165,44 @@ export default class DetailedStatus extends ImmutablePureComponent {
}
if (status.get('application')) {
applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>;
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
}
if (status.get('visibility') === 'direct') {
reblogIcon = 'envelope';
} else if (status.get('visibility') === 'private') {
reblogIcon = 'lock';
}
const visibilityIconInfo = {
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
const visibilityLink = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
if (['private', 'direct'].includes(status.get('visibility'))) {
reblogLink = <Icon id={reblogIcon} />;
reblogLink = '';
} else if (this.context.router) {
reblogLink = (
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</Link>
<React.Fragment>
<React.Fragment> · </React.Fragment>
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</Link>
</React.Fragment>
);
} else {
reblogLink = (
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</a>
<React.Fragment>
<React.Fragment> · </React.Fragment>
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} />
<span className='detailed-status__reblogs'>
<AnimatedNumber value={status.get('reblogs_count')} />
</span>
</a>
</React.Fragment>
);
}
@ -210,7 +228,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
return (
<div style={outerStyle}>
<div ref={this.setRef} className={classNames('detailed-status', { compact })}>
<div ref={this.setRef} className={classNames('detailed-status', `detailed-status-${status.get('visibility')}`, { compact })}>
<a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={48} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
@ -223,7 +241,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
<FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
</a>{applicationLink} · {reblogLink} · {favouriteLink}
</a>{visibilityLink}{applicationLink}{reblogLink} · {favouriteLink}
</div>
</div>
</div>

View File

@ -177,15 +177,26 @@ class Video extends React.PureComponent {
handlePlay = () => {
this.setState({ paused: false });
this._updateTime();
}
handlePause = () => {
this.setState({ paused: true });
}
_updateTime () {
requestAnimationFrame(() => {
this.handleTimeUpdate();
if (!this.state.paused) {
this._updateTime();
}
});
}
handleTimeUpdate = () => {
this.setState({
currentTime: Math.floor(this.video.currentTime),
currentTime: this.video.currentTime,
duration: Math.floor(this.video.duration),
});
}
@ -217,7 +228,7 @@ class Video extends React.PureComponent {
this.video.volume = x;
});
}
}, 60);
}, 15);
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
@ -245,13 +256,14 @@ class Video extends React.PureComponent {
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = Math.floor(this.video.duration * x);
const currentTime = this.video.duration * x;
if (!isNaN(currentTime)) {
this.video.currentTime = currentTime;
this.setState({ currentTime });
this.setState({ currentTime }, () => {
this.video.currentTime = currentTime;
});
}
}, 60);
}, 15);
togglePlay = () => {
if (this.state.paused) {
@ -387,8 +399,10 @@ class Video extends React.PureComponent {
}
handleProgress = () => {
if (this.video.buffered.length > 0) {
this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
const lastTimeRange = this.video.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
}
}
@ -484,7 +498,6 @@ class Video extends React.PureComponent {
onClick={this.togglePlay}
onPlay={this.handlePlay}
onPause={this.handlePause}
onTimeUpdate={this.handleTimeUpdate}
onLoadedData={this.handleLoadedData}
onProgress={this.handleProgress}
onVolumeChange={this.handleVolumeChange}
@ -525,7 +538,7 @@ class Video extends React.PureComponent {
{(detailed || fullscreen) && (
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(duration)}</span>
</span>

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "El borrador va perdese si coles de Mastodon.",
"upload_area.title": "Arrastra y suelta pa xubir",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "La xuba de ficheros nun ta permitida con encuestes.",
"upload_form.audio_description": "Descripción pa persones con perda auditiva",

View File

@ -1206,7 +1206,7 @@
{
"descriptors": [
{
"defaultMessage": "Add media ({formats})",
"defaultMessage": "Add images, a video or an audio file",
"id": "upload_button.label"
}
],

View File

@ -427,7 +427,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -422,7 +422,7 @@
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",
"upload_button.label": "Add images, a video or an audio file",
"upload_error.limit": "File upload limit exceeded.",
"upload_error.poll": "File upload not allowed with polls.",
"upload_form.audio_description": "Describe for people with hearing loss",

View File

@ -171,9 +171,7 @@ $content-width: 840px;
}
.content {
padding: 20px 15px;
padding-top: 60px;
padding-left: 25px;
padding: 55px 15px 20px 25px;
@media screen and (max-width: $no-columns-breakpoint) {
max-width: none;
@ -184,7 +182,7 @@ $content-width: 840px;
&-heading {
display: flex;
padding-bottom: 40px;
padding-bottom: 36px;
border-bottom: 1px solid lighten($ui-base-color, 8%);
margin: -15px -15px 40px 0;
@ -215,7 +213,7 @@ $content-width: 840px;
h2 {
color: $secondary-text-color;
font-size: 24px;
line-height: 28px;
line-height: 36px;
font-weight: 400;
@media screen and (max-width: $no-columns-breakpoint) {
@ -544,6 +542,16 @@ body,
max-width: 100%;
}
.simple_form {
.actions {
margin-top: 15px;
}
.button {
font-size: 15px;
}
}
.batch-form-box {
display: flex;
flex-wrap: wrap;

View File

@ -68,7 +68,32 @@ body {
}
&.player {
text-align: center;
padding: 0;
margin: 0;
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
& > div {
height: 100%;
}
.video-player video {
width: 100%;
height: 100%;
max-height: 100vh;
}
.media-gallery {
margin-top: 0;
height: 100% !important;
border-radius: 0;
}
.media-gallery__item {
border-radius: 0;
}
}
&.embed {

View File

@ -1019,7 +1019,8 @@
}
&.light {
.status__relative-time {
.status__relative-time,
.status__visibility-icon {
color: $light-text-color;
}
@ -1065,12 +1066,18 @@
}
.status__relative-time,
.status__visibility-icon,
.notification__relative_time {
color: $dark-text-color;
float: right;
font-size: 14px;
}
.status__visibility-icon {
margin-left: 4px;
margin-right: 4px;
}
.status__display-name {
color: $dark-text-color;
}
@ -3003,6 +3010,7 @@ a.account__display-name {
}
.status-card {
position: relative;
display: flex;
font-size: 14px;
border: 1px solid lighten($ui-base-color, 8%);

View File

@ -158,6 +158,7 @@ body.rtl {
}
.status__relative-time,
.status__visibility-icon,
.activity-stream .status.light .status__header .status__meta {
float: left;
}

View File

@ -140,6 +140,11 @@
.detailed-status {
padding: 15px;
.detailed-status__display-avatar .account__avatar {
width: 48px;
height: 48px;
}
}
.status {

View File

@ -4,7 +4,7 @@ class LanguageDetector
include Singleton
WORDS_THRESHOLD = 4
RELIABLE_CHARACTERS_RE = /[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}\p{Han}\p{Katakana}\p{Hiragana}\p{Hangul}]+/m
RELIABLE_CHARACTERS_RE = /[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}\p{Han}\p{Katakana}\p{Hiragana}\p{Hangul}\p{Thai}]+/m
def initialize
@identifier = CLD3::NNetLanguageIdentifier.new(1, 2048)

View File

@ -194,15 +194,17 @@ class MediaAttachment < ApplicationRecord
x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
meta = file.instance_read(:meta) || {}
meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small)
meta['focus'] = { 'x' => x, 'y' => y }
file.instance_write(:meta, meta)
end
def focus
x = file.meta['focus']['x']
y = file.meta['focus']['y']
x = file.meta&.dig('focus', 'x')
y = file.meta&.dig('focus', 'y')
return if x.nil? || y.nil?
"#{x},#{y}"
end
@ -219,12 +221,11 @@ class MediaAttachment < ApplicationRecord
before_create :prepare_description, unless: :local?
before_create :set_shortcode
before_create :set_processing
before_create :set_meta
before_post_process :set_type_and_extension
before_post_process :check_video_dimensions
before_save :set_meta
class << self
def supported_mime_types
IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
@ -306,15 +307,11 @@ class MediaAttachment < ApplicationRecord
end
def set_meta
meta = populate_meta
return if meta == {}
file.instance_write :meta, meta
file.instance_write :meta, populate_meta
end
def populate_meta
meta = file.instance_read(:meta) || {}
meta = (file.instance_read(:meta) || {}).with_indifferent_access.slice(:focus, :original, :small)
file.queued_for_write.each do |style, file|
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)

View File

@ -7,7 +7,7 @@
= opengraph 'og:title', yield(:page_title).strip
= opengraph 'og:description', description
= opengraph 'og:image', full_asset_url(account.avatar.url(:original))
= opengraph 'og:image:width', '120'
= opengraph 'og:image:height', '120'
= opengraph 'og:image:width', '400'
= opengraph 'og:image:height', '400'
= opengraph 'twitter:card', 'summary'
= opengraph 'profile:username', acct(account)[1..-1]

View File

@ -38,7 +38,7 @@
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}")
.actions
%button= t('admin.accounts.search')
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative'
.table-wrapper

View File

@ -31,7 +31,7 @@
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.custom_emojis.#{key}")
.actions
%button= t('admin.accounts.search')
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_custom_emojis_path, class: 'button negative'
= form_for(@form, url: batch_admin_custom_emojis_path) do |f|

View File

@ -27,7 +27,7 @@
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.instances.#{key}")
.actions
%button= t('admin.accounts.search')
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_instances_path, class: 'button negative'
%hr.spacer/

View File

@ -18,7 +18,7 @@
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.reports.#{key}")
.actions
%button= t('admin.accounts.search')
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_reports_path, class: 'button negative'
- @reports.group_by(&:target_account_id).each do |target_account_id, reports|

View File

@ -33,7 +33,7 @@
= text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.tags.#{key}")
.actions
%button= t('admin.accounts.search')
%button.button= t('admin.accounts.search')
= link_to t('admin.accounts.reset'), admin_tags_path, class: 'button negative'
%hr.spacer/

View File

@ -1,2 +1,16 @@
%video{ poster: @media_attachment.file.url(:small), preload: 'auto', autoplay: 'autoplay', muted: 'muted', loop: 'loop', controls: 'controls', style: "width: #{@media_attachment.file.meta.dig('original', 'width')}px; height: #{@media_attachment.file.meta.dig('original', 'height')}px" }
%source{ src: @media_attachment.file.url(:original), type: @media_attachment.file_content_type }
- content_for :header_tags do
= render_initial_state
= javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous'
- if @media_attachment.video?
= react_component :video, src: @media_attachment.file.url(:original), preview: @media_attachment.file.url(:small), blurhash: @media_attachment.blurhash, width: 670, height: 380, editable: true, detailed: true, inline: true, alt: @media_attachment.description do
%video{ controls: 'controls' }
%source{ src: @media_attachment.file.url(:original) }
- elsif @media_attachment.gifv?
= react_component :media_gallery, height: 380, standalone: true, autoplay: true, media: [ActiveModelSerializers::SerializableResource.new(@media_attachment, serializer: REST::MediaAttachmentSerializer).as_json] do
%video{ autoplay: 'autoplay', muted: 'muted', loop: 'loop' }
%source{ src: @media_attachment.file.url(:original) }
- elsif @media_attachment.audio?
= react_component :audio, src: @media_attachment.file.url(:original), poster: full_asset_url(@media_attachment.account.avatar_static_url), width: 670, height: 380, fullscreen: true, alt: @media_attachment.description, duration: @media_attachment.file.meta.dig(:original, :duration) do
%audio{ controls: 'controls' }
%source{ src: @media_attachment.file.url(:original) }

View File

@ -1,4 +1,4 @@
.detailed-status.detailed-status--flex
.detailed-status.detailed-status--flex{ class: "detailed-status-#{status.visibility}" }
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'detailed-status__display-name u-url', target: stream_link_target, rel: 'noopener' do
.detailed-status__display-avatar
@ -33,7 +33,7 @@
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.file.meta.dig(:original, :duration) do
= react_component :audio, src: audio.file.url(:original), poster: full_asset_url(status.account.avatar_static_url), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
@ -47,6 +47,9 @@
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
·
%span.detailed-status__visibility-icon
= visibility_icon status
·
- if status.application && @account.user&.setting_show_application
- if status.application.website.blank?
%strong.detailed-status__application= status.application.name
@ -61,18 +64,12 @@
%span.detailed-status__reblogs>= number_to_human status.replies_count, strip_insignificant_zeros: true
= " "
·
- if status.direct_visibility?
%span.detailed-status__link<
= fa_icon('envelope')
- elsif status.private_visibility? || status.limited_visibility?
%span.detailed-status__link<
= fa_icon('lock')
- else
- if status.public_visibility? || status.unlisted_visibility?
= link_to remote_interaction_path(status, type: :reblog), class: 'modal-button detailed-status__link' do
= fa_icon('retweet')
%span.detailed-status__reblogs>= number_to_human status.reblogs_count, strip_insignificant_zeros: true
= " "
·
·
= link_to remote_interaction_path(status, type: :favourite), class: 'modal-button detailed-status__link' do
= fa_icon('star')
%span.detailed-status__favorites>= number_to_human status.favourites_count, strip_insignificant_zeros: true

View File

@ -27,12 +27,25 @@
= opengraph 'og:video:height', media.file.meta.dig('original', 'height')
= opengraph 'twitter:player:width', media.file.meta.dig('original', 'width')
= opengraph 'twitter:player:height', media.file.meta.dig('original', 'height')
- elsif media.audio?
- player_card = true
= opengraph 'og:image', full_asset_url(account.avatar.url(:original))
= opengraph 'og:image:width', '400'
= opengraph 'og:image:height','400'
= opengraph 'og:audio', full_asset_url(media.file.url(:original))
= opengraph 'og:audio:secure_url', full_asset_url(media.file.url(:original))
= opengraph 'og:audio:type', media.file_content_type
= opengraph 'twitter:player', medium_player_url(media)
= opengraph 'twitter:player:stream', full_asset_url(media.file.url(:original))
= opengraph 'twitter:player:stream:content_type', media.file_content_type
= opengraph 'twitter:player:width', '670'
= opengraph 'twitter:player:height', '380'
- if player_card
= opengraph 'twitter:card', 'player'
- else
= opengraph 'twitter:card', 'summary_large_image'
- else
= opengraph 'og:image', full_asset_url(account.avatar.url(:original))
= opengraph 'og:image:width', '120'
= opengraph 'og:image:height','120'
= opengraph 'og:image:width', '400'
= opengraph 'og:image:height','400'
= opengraph 'twitter:card', 'summary'

View File

@ -1,8 +1,10 @@
.status
.status{ class: "status-#{status.visibility}" }
.status__info
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__relative-time u-url u-uid', target: stream_link_target, rel: 'noopener noreferrer' do
%time.time-ago{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
%data.dt-published{ value: status.created_at.to_time.iso8601 }
%span.status__visibility-icon
= visibility_icon status
.p-author.h-card
= link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name u-url', target: stream_link_target, rel: 'noopener noreferrer' do
@ -37,7 +39,7 @@
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
= react_component :audio, src: audio.file.url(:original), poster: full_asset_url(status.account.avatar_static_url), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do

View File

@ -25,8 +25,14 @@ class PostProcessMediaWorker
media_attachment = MediaAttachment.find(media_attachment_id)
media_attachment.processing = :in_progress
media_attachment.save
# Because paperclip-av-transcover overwrites this attribute
# we will save it here and restore it after reprocess is done
previous_meta = media_attachment.file_meta
media_attachment.file.reprocess!(:original)
media_attachment.processing = :complete
media_attachment.file_meta = previous_meta
media_attachment.save
rescue ActiveRecord::RecordNotFound
true