Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote

This commit is contained in:
KMY 2025-05-07 09:39:51 +09:00
commit 9de0de7d65
66 changed files with 2420 additions and 1868 deletions

View file

@ -2,9 +2,34 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [4.3.8] - 2025-05-06
### Security
- Update dependencies
- Check scheme on account, profile, and media URLs ([GHSA-x2rc-v5wx-g3m5](https://github.com/mastodon/mastodon/security/advisories/GHSA-x2rc-v5wx-g3m5))
### Added
- Add warning for REDIS_NAMESPACE deprecation at startup (#34581 by @ClearlyClaire)
- Add built-in context for interaction policies (#34574 by @ClearlyClaire)
### Changed
- Change activity distribution error handling to skip retrying for deleted accounts (#33617 by @ClearlyClaire)
### Removed
- Remove double-query for signed query strings (#34610 by @ClearlyClaire)
### Fixed
- Fix incorrect redirect in response to unauthenticated API requests in limited federation mode (#34549 by @ClearlyClaire)
- Fix sign-up e-mail confirmation page reloading on error or redirect (#34548 by @ClearlyClaire)
## [4.3.7] - 2025-04-02 ## [4.3.7] - 2025-04-02
### Add ### Added
- Add delay to profile updates to debounce them (#34137 by @ClearlyClaire) - Add delay to profile updates to debounce them (#34137 by @ClearlyClaire)
- Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire) - Add support for paginating partial collections in `SynchronizeFollowersService` (#34272 and #34277 by @ClearlyClaire)

View file

@ -212,7 +212,7 @@ group :development, :test do
gem 'test-prof', require: false gem 'test-prof', require: false
# RSpec runner for rails # RSpec runner for rails
gem 'rspec-rails', '~> 7.0' gem 'rspec-rails', '~> 8.0'
end end
group :production do group :production do

View file

@ -435,7 +435,7 @@ GEM
mutex_m (0.3.0) mutex_m (0.3.0)
net-http (0.6.0) net-http (0.6.0)
uri uri
net-imap (0.5.6) net-imap (0.5.8)
date date
net-protocol net-protocol
net-ldap (0.19.0) net-ldap (0.19.0)
@ -620,7 +620,7 @@ GEM
psych (5.2.3) psych (5.2.3)
date date
stringio stringio
public_suffix (6.0.1) public_suffix (6.0.2)
puma (6.6.0) puma (6.6.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.5.0) pundit (2.5.0)
@ -721,18 +721,18 @@ GEM
rspec-mocks (~> 3.13.0) rspec-mocks (~> 3.13.0)
rspec-core (3.13.3) rspec-core (3.13.3)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-expectations (3.13.3) rspec-expectations (3.13.4)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-github (3.0.0) rspec-github (3.0.0)
rspec-core (~> 3.0) rspec-core (~> 3.0)
rspec-mocks (3.13.2) rspec-mocks (3.13.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0) rspec-support (~> 3.13.0)
rspec-rails (7.1.1) rspec-rails (8.0.0)
actionpack (>= 7.0) actionpack (>= 7.2)
activesupport (>= 7.0) activesupport (>= 7.2)
railties (>= 7.0) railties (>= 7.2)
rspec-core (~> 3.13) rspec-core (~> 3.13)
rspec-expectations (~> 3.13) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13) rspec-mocks (~> 3.13)
@ -742,8 +742,8 @@ GEM
rspec-expectations (~> 3.0) rspec-expectations (~> 3.0)
rspec-mocks (~> 3.0) rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9) sidekiq (>= 5, < 9)
rspec-support (3.13.2) rspec-support (3.13.3)
rubocop (1.75.4) rubocop (1.75.5)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
@ -800,7 +800,7 @@ GEM
activerecord (>= 4.0.0) activerecord (>= 4.0.0)
railties (>= 4.0.0) railties (>= 4.0.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.31.0) selenium-webdriver (4.32.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
@ -842,7 +842,7 @@ GEM
base64 base64
stoplight (4.1.1) stoplight (4.1.1)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.1.6) stringio (3.1.7)
strong_migrations (2.3.0) strong_migrations (2.3.0)
activerecord (>= 7) activerecord (>= 7)
swd (2.0.3) swd (2.0.3)
@ -1045,7 +1045,7 @@ DEPENDENCIES
redis-namespace (~> 1.10) redis-namespace (~> 1.10)
rqrcode (~> 3.0) rqrcode (~> 3.0)
rspec-github (~> 3.0) rspec-github (~> 3.0)
rspec-rails (~> 7.0) rspec-rails (~> 8.0)
rspec-sidekiq (~> 5.0) rspec-sidekiq (~> 5.0)
rubocop rubocop
rubocop-capybara rubocop-capybara

View file

@ -80,6 +80,18 @@ module JsonLdHelper
!haystack.casecmp(needle).zero? !haystack.casecmp(needle).zero?
end end
def safe_prefetched_embed(account, object, context)
return unless object.is_a?(Hash)
# NOTE: Replacing the object's context by that of the parent activity is
# not sound, but it's consistent with the rest of the codebase
object = object.merge({ '@context' => context })
return if value_or_id(first_of_value(object['attributedTo'])) != account.uri || non_matching_uri_hosts?(account.uri, object['id'])
object
end
def canonicalize(json) def canonicalize(json)
graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context)) graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
graph.dump(:normalize) graph.dump(:normalize)

View file

@ -89,6 +89,17 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null;
}
normalStatus.url ||= normalStatus.uri;
normalStatus.media_attachments.forEach(item => {
if (item.remote_url && !(item.remote_url.startsWith('http://') || item.remote_url.startsWith('https://')))
item.remote_url = null;
});
} }
if (normalOldStatus) { if (normalOldStatus) {

View file

@ -96,13 +96,19 @@ export const decode83 = (str: string) => {
return value; return value;
}; };
export const intToRGB = (int: number) => ({ export interface RGB {
r: number;
g: number;
b: number;
}
export const intToRGB = (int: number): RGB => ({
r: Math.max(0, int >> 16), r: Math.max(0, int >> 16),
g: Math.max(0, (int >> 8) & 255), g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, int & 255), b: Math.max(0, int & 255),
}); });
export const getAverageFromBlurhash = (blurhash: string) => { export const getAverageFromBlurhash = (blurhash: string | null) => {
if (!blurhash) { if (!blurhash) {
return null; return null;
} }

View file

@ -105,7 +105,7 @@ class ReportReasonSelector extends PureComponent {
}; };
componentDidMount() { componentDidMount() {
api(false).get('/api/v1/instance').then(res => { api(false).get('/api/v2/instance').then(res => {
this.setState({ this.setState({
rules: res.data.rules, rules: res.data.rules,
}); });

View file

@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import CancelPresentationIcon from '@/material-icons/400-24px/cancel_presentation.svg?react';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { Icon } from 'mastodon/components/icon';
class PictureInPicturePlaceholder extends PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
aspectRatio: PropTypes.string,
};
handleClick = () => {
const { dispatch } = this.props;
dispatch(removePictureInPicture());
};
render () {
const { aspectRatio } = this.props;
return (
<div className='picture-in-picture-placeholder' style={{ aspectRatio }} role='button' tabIndex={0} onClick={this.handleClick}>
<Icon id='window-restore' icon={CancelPresentationIcon} />
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
</div>
);
}
}
export default connect()(PictureInPicturePlaceholder);

View file

@ -0,0 +1,46 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import PipExitIcon from '@/material-icons/400-24px/pip_exit.svg?react';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { Icon } from 'mastodon/components/icon';
import { useAppDispatch } from 'mastodon/store';
export const PictureInPicturePlaceholder: React.FC<{ aspectRatio: string }> = ({
aspectRatio,
}) => {
const dispatch = useAppDispatch();
const handleClick = useCallback(() => {
dispatch(removePictureInPicture());
}, [dispatch]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleClick();
}
},
[handleClick],
);
return (
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
className='picture-in-picture-placeholder'
style={{ aspectRatio }}
role='button'
tabIndex={0}
onClick={handleClick}
onKeyDownCapture={handleKeyDown}
>
<Icon id='' icon={PipExitIcon} />
<FormattedMessage
id='picture_in_picture.restore'
defaultMessage='Put it back'
/>
</div>
);
};

View file

@ -21,7 +21,7 @@ import AttachmentList from 'mastodon/components/attachment_list';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router'; import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
@ -507,9 +507,6 @@ class Status extends ImmutablePureComponent {
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])} accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)} duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}

View file

@ -8,7 +8,7 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
import MediaGallery from 'mastodon/components/media_gallery'; import MediaGallery from 'mastodon/components/media_gallery';
import ModalRoot from 'mastodon/components/modal_root'; import ModalRoot from 'mastodon/components/modal_root';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import Audio from 'mastodon/features/audio'; import { Audio } from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card'; import Card from 'mastodon/features/status/components/card';
import MediaModal from 'mastodon/features/ui/components/media_modal'; import MediaModal from 'mastodon/features/ui/components/media_modal';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';

View file

@ -27,7 +27,7 @@ import { Button } from 'mastodon/components/button';
import { GIFV } from 'mastodon/components/gifv'; import { GIFV } from 'mastodon/components/gifv';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
import Audio from 'mastodon/features/audio'; import { Audio } from 'mastodon/features/audio';
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter'; import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
import { Video, getPointerPosition } from 'mastodon/features/video'; import { Video, getPointerPosition } from 'mastodon/features/video';
@ -212,11 +212,11 @@ const Preview: React.FC<{
return ( return (
<Audio <Audio
src={media.get('url') as string} src={media.get('url') as string}
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
poster={ poster={
(media.get('preview_url') as string | undefined) ?? (media.get('preview_url') as string | undefined) ??
account?.avatar_static account?.avatar_static
} }
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={ backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string media.getIn(['meta', 'colors', 'background']) as string
} }

View file

@ -1,588 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { is } from 'immutable';
import { throttle, debounce } from 'lodash';
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
import PauseIcon from '@/material-icons/400-24px/pause.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
import { Blurhash } from '../../components/blurhash';
import { displayMedia, useBlurhash } from '../../initial_state';
import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
download: { id: 'video.download', defaultMessage: 'Download file' },
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
});
const TICK_SIZE = 10;
const PADDING = 180;
class Audio extends PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
lang: PropTypes.string,
poster: PropTypes.string,
duration: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
editable: PropTypes.bool,
fullscreen: PropTypes.bool,
intl: PropTypes.object.isRequired,
blurhash: PropTypes.string,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
matchedFilters: PropTypes.arrayOf(PropTypes.string),
};
state = {
width: this.props.width,
currentTime: 0,
buffer: 0,
duration: null,
paused: true,
muted: false,
volume: 1,
dragging: false,
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
};
constructor (props) {
super(props);
this.visualizer = new Visualizer(TICK_SIZE);
}
setPlayerRef = c => {
this.player = c;
if (this.player) {
this._setDimensions();
}
};
_pack() {
return {
src: this.props.src,
volume: this.state.volume,
muted: this.state.muted,
currentTime: this.audio.currentTime,
poster: this.props.poster,
backgroundColor: this.props.backgroundColor,
foregroundColor: this.props.foregroundColor,
accentColor: this.props.accentColor,
sensitive: this.props.sensitive,
visible: this.props.visible,
};
}
_setDimensions () {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width, height });
}
setSeekRef = c => {
this.seek = c;
};
setVolumeRef = c => {
this.volume = c;
};
setAudioRef = c => {
this.audio = c;
if (this.audio) {
this.audio.volume = 1;
this.audio.muted = false;
}
};
setCanvasRef = c => {
this.canvas = c;
this.visualizer.setCanvas(c);
};
componentDidMount () {
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentDidUpdate (prevProps, prevState) {
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
this._clear();
this._draw();
}
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
this.setState({ revealed: nextProps.visible });
}
}
componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
}
togglePlay = () => {
if (!this.audioContext) {
this._initAudioContext();
}
if (this.state.paused) {
this.setState({ paused: false }, () => this.audio.play());
} else {
this.setState({ paused: true }, () => this.audio.pause());
}
};
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePlay = () => {
this.setState({ paused: false });
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
this._renderCanvas();
};
handlePause = () => {
this.setState({ paused: true });
if (this.audioContext) {
this.audioContext.suspend();
}
};
handleProgress = () => {
const lastTimeRange = this.audio.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
}
};
toggleMute = () => {
const muted = !(this.state.muted || this.state.volume === 0);
this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
}
});
};
toggleReveal = () => {
if (this.props.onToggleVisibility) {
this.props.onToggleVisibility();
} else {
this.setState({ revealed: !this.state.revealed });
}
};
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
this.handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
};
handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
};
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('mouseup', this.handleMouseUp, true);
document.addEventListener('touchmove', this.handleMouseMove, true);
document.addEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: true });
this.audio.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
};
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('mouseup', this.handleMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseMove, true);
document.removeEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: false });
this.audio.play();
};
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = this.audio.duration * x;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}, 15);
handleTimeUpdate = () => {
this.setState({
currentTime: this.audio.currentTime,
duration: this.audio.duration,
});
};
handleMouseVolSlide = throttle(e => {
const { x } = getPointerPosition(this.volume, e);
if(!isNaN(x)) {
this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
if (this.gainNode) {
this.gainNode.gain.value = this.state.muted ? 0 : x;
}
});
}
}, 15);
handleScroll = throttle(() => {
if (!this.canvas || !this.audio) {
return;
}
const { top, height } = this.canvas.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
this.audio.pause();
if (this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
this.setState({ paused: true });
}
}, 150, { trailing: true });
handleMouseEnter = () => {
this.setState({ hovered: true });
};
handleMouseLeave = () => {
this.setState({ hovered: false });
};
handleLoadedData = () => {
const { autoPlay, currentTime } = this.props;
if (currentTime) {
this.audio.currentTime = currentTime;
}
if (autoPlay) {
this.togglePlay();
}
};
_initAudioContext () {
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
const source = context.createMediaElementSource(this.audio);
const gainNode = context.createGain();
gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
this.visualizer.setAudioContext(context, source);
source.connect(gainNode);
gainNode.connect(context.destination);
this.audioContext = context;
this.gainNode = gainNode;
}
handleDownload = () => {
fetch(this.props.src).then(res => res.blob()).then(blob => {
const element = document.createElement('a');
const objectURL = URL.createObjectURL(blob);
element.setAttribute('href', objectURL);
element.setAttribute('download', fileNameFromURL(this.props.src));
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(objectURL);
}).catch(err => {
console.error(err);
});
};
_renderCanvas () {
requestAnimationFrame(() => {
if (!this.audio) return;
this.handleTimeUpdate();
this._clear();
this._draw();
if (!this.state.paused) {
this._renderCanvas();
}
});
}
_clear() {
this.visualizer.clear(this.state.width, this.state.height);
}
_draw() {
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
}
_getRadius () {
return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
}
_getScaleCoefficient () {
return (this.state.height || this.props.height) / 982;
}
_getCX() {
return Math.floor(this.state.width / 2);
}
_getCY() {
return Math.floor((this.state.height || this.props.height) / 2);
}
_getAccentColor () {
return this.props.accentColor || '#ffffff';
}
_getBackgroundColor () {
return this.props.backgroundColor || '#000000';
}
_getForegroundColor () {
return this.props.foregroundColor || '#ffffff';
}
seekBy (time) {
const currentTime = this.audio.currentTime + time;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}
handleAudioKeyDown = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
this.togglePlay();
}
};
handleKeyDown = e => {
switch(e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
this.togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
this.toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
this.seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
this.seekBy(10);
break;
}
};
render () {
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
const progress = Math.min((currentTime / duration) * 100, 100);
const muted = this.state.muted || volume === 0;
return (
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
<Blurhash
hash={blurhash}
className={classNames('media-gallery__preview', {
'media-gallery__preview--hidden': revealed,
})}
dummy={!useBlurhash}
/>
{(revealed || editable) && <audio
src={src}
ref={this.setAudioRef}
preload={autoPlay ? 'auto' : 'none'}
onPlay={this.handlePlay}
onPause={this.handlePause}
onProgress={this.handleProgress}
onLoadedData={this.handleLoadedData}
crossOrigin='anonymous'
/>}
<canvas
role='button'
tabIndex={0}
className='audio-player__canvas'
width={this.state.width}
height={this.state.height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={this.setCanvasRef}
onClick={this.togglePlay}
onKeyDown={this.handleAudioKeyDown}
title={alt}
aria-label={alt}
lang={lang}
/>
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
{(revealed || editable) && <img
src={this.props.poster}
alt=''
style={{
position: 'absolute',
left: '50%',
top: '50%',
height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
aspectRatio: '1',
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
pointerEvents: 'none',
}}
/>}
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex={0}
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
onKeyDown={this.handleAudioKeyDown}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} icon={paused ? PlayArrowIcon : PauseIcon} /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} icon={muted ? VolumeOffIcon : VolumeUpIcon} /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span
className='video-player__volume__handle'
tabIndex={0}
style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }}
/>
</div>
<span className='video-player__time'>
<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(Math.floor(this.state.duration || this.props.duration))}</span>
</span>
</div>
<div className='video-player__buttons right'>
{!editable && (
<>
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id='download' icon={DownloadIcon} />
</a>
</>
)}
</div>
</div>
</div>
</div>
);
}
}
export default injectIntl(Audio);

View file

@ -0,0 +1,840 @@
import { useEffect, useRef, useCallback, useState, useId } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useSpring, animated, config } from '@react-spring/web';
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
import { SpoilerButton } from 'mastodon/components/spoiler_button';
import { formatTime, getPointerPosition } from 'mastodon/features/video';
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
import {
displayMedia,
useBlurhash,
reduceMotion,
} from 'mastodon/initial_state';
import { playerSettings } from 'mastodon/settings';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
download: { id: 'video.download', defaultMessage: 'Download file' },
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
});
const persistVolume = (volume: number, muted: boolean) => {
playerSettings.set('volume', volume);
playerSettings.set('muted', muted);
};
const restoreVolume = (audio: HTMLAudioElement) => {
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
audio.volume = volume;
audio.muted = muted;
};
const HOVER_FADE_DELAY = 4000;
export const Audio: React.FC<{
src: string;
alt?: string;
lang?: string;
poster?: string;
sensitive?: boolean;
editable?: boolean;
blurhash?: string;
visible?: boolean;
duration?: number;
onToggleVisibility?: () => void;
backgroundColor?: string;
foregroundColor?: string;
accentColor?: string;
startTime?: number;
startPlaying?: boolean;
startVolume?: number;
startMuted?: boolean;
deployPictureInPicture?: (
type: string,
mediaProps: {
src: string;
muted: boolean;
volume: number;
currentTime: number;
poster?: string;
backgroundColor: string;
foregroundColor: string;
accentColor: string;
},
) => void;
matchedFilters?: string[];
}> = ({
src,
alt,
lang,
poster,
duration,
sensitive,
editable,
blurhash,
visible,
onToggleVisibility,
backgroundColor = '#000000',
foregroundColor = '#ffffff',
accentColor = '#ffffff',
startTime,
startPlaying,
startVolume,
startMuted,
deployPictureInPicture,
matchedFilters,
}) => {
const intl = useIntl();
const [currentTime, setCurrentTime] = useState(0);
const [loadedDuration, setDuration] = useState(duration ?? 0);
const [paused, setPaused] = useState(true);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(0.5);
const [hovered, setHovered] = useState(false);
const [dragging, setDragging] = useState(false);
const [revealed, setRevealed] = useState(false);
const playerRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const seekRef = useRef<HTMLDivElement>(null);
const volumeRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
audioRef,
3,
);
const accessibilityId = useId();
const [style, spring] = useSpring(() => ({
progress: '0%',
buffer: '0%',
volume: '0%',
}));
const handleAudioRef = useCallback(
(c: HTMLVideoElement | null) => {
if (audioRef.current && !audioRef.current.paused && c === null) {
deployPictureInPicture?.('audio', {
src,
poster,
backgroundColor,
foregroundColor,
accentColor,
currentTime: audioRef.current.currentTime,
muted: audioRef.current.muted,
volume: audioRef.current.volume,
});
}
audioRef.current = c;
if (audioRef.current) {
restoreVolume(audioRef.current);
setVolume(audioRef.current.volume);
setMuted(audioRef.current.muted);
void spring.start({
volume: `${audioRef.current.volume * 100}%`,
immediate: reduceMotion,
});
}
},
[
spring,
setVolume,
setMuted,
src,
poster,
backgroundColor,
accentColor,
foregroundColor,
deployPictureInPicture,
],
);
useEffect(() => {
if (!audioRef.current) {
return;
}
audioRef.current.volume = volume;
audioRef.current.muted = muted;
}, [volume, muted]);
useEffect(() => {
if (typeof visible !== 'undefined') {
setRevealed(visible);
} else {
setRevealed(
displayMedia === 'show_all' ||
(displayMedia !== 'hide_all' && !sensitive),
);
}
}, [visible, sensitive]);
useEffect(() => {
if (!revealed && audioRef.current) {
audioRef.current.pause();
suspendAudio();
}
}, [suspendAudio, revealed]);
useEffect(() => {
let nextFrame: ReturnType<typeof requestAnimationFrame>;
const updateProgress = () => {
nextFrame = requestAnimationFrame(() => {
if (audioRef.current && audioRef.current.duration > 0) {
void spring.start({
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
immediate: reduceMotion,
config: config.stiff,
});
}
updateProgress();
});
};
updateProgress();
return () => {
cancelAnimationFrame(nextFrame);
};
}, [spring]);
const togglePlay = useCallback(() => {
if (!audioRef.current) {
return;
}
if (audioRef.current.paused) {
resumeAudio();
void audioRef.current.play();
} else {
audioRef.current.pause();
suspendAudio();
}
}, [resumeAudio, suspendAudio]);
const handlePlay = useCallback(() => {
setPaused(false);
}, []);
const handlePause = useCallback(() => {
setPaused(true);
}, []);
const handleProgress = useCallback(() => {
if (!audioRef.current) {
return;
}
const lastTimeRange = audioRef.current.buffered.length - 1;
if (lastTimeRange > -1) {
void spring.start({
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
immediate: reduceMotion,
});
}
}, [spring]);
const handleVolumeChange = useCallback(() => {
if (!audioRef.current) {
return;
}
setVolume(audioRef.current.volume);
setMuted(audioRef.current.muted);
void spring.start({
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
immediate: reduceMotion,
});
persistVolume(audioRef.current.volume, audioRef.current.muted);
}, [spring, setVolume, setMuted]);
const handleTimeUpdate = useCallback(() => {
if (!audioRef.current) {
return;
}
setCurrentTime(audioRef.current.currentTime);
}, [setCurrentTime]);
const toggleMute = useCallback(() => {
if (!audioRef.current) {
return;
}
const effectivelyMuted =
audioRef.current.muted || audioRef.current.volume === 0;
if (effectivelyMuted) {
audioRef.current.muted = false;
if (audioRef.current.volume === 0) {
audioRef.current.volume = 0.05;
}
} else {
audioRef.current.muted = true;
}
}, []);
const toggleReveal = useCallback(() => {
if (onToggleVisibility) {
onToggleVisibility();
} else {
setRevealed((value) => !value);
}
}, [onToggleVisibility, setRevealed]);
const handleVolumeMouseDown = useCallback(
(e: React.MouseEvent) => {
const handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', handleVolumeMouseMove, true);
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
};
const handleVolumeMouseMove = (e: MouseEvent) => {
if (!volumeRef.current || !audioRef.current) {
return;
}
const { x } = getPointerPosition(volumeRef.current, e);
if (!isNaN(x)) {
audioRef.current.volume = x;
audioRef.current.muted = x > 0 ? false : true;
void spring.start({ volume: `${x * 100}%`, immediate: true });
}
};
document.addEventListener('mousemove', handleVolumeMouseMove, true);
document.addEventListener('mouseup', handleVolumeMouseUp, true);
handleVolumeMouseMove(e.nativeEvent);
e.preventDefault();
e.stopPropagation();
},
[spring],
);
const handleSeekMouseDown = useCallback(
(e: React.MouseEvent) => {
const handleSeekMouseUp = () => {
document.removeEventListener('mousemove', handleSeekMouseMove, true);
document.removeEventListener('mouseup', handleSeekMouseUp, true);
setDragging(false);
resumeAudio();
void audioRef.current?.play();
};
const handleSeekMouseMove = (e: MouseEvent) => {
if (!seekRef.current || !audioRef.current) {
return;
}
const { x } = getPointerPosition(seekRef.current, e);
const newTime = audioRef.current.duration * x;
if (!isNaN(newTime)) {
audioRef.current.currentTime = newTime;
void spring.start({ progress: `${x * 100}%`, immediate: true });
}
};
document.addEventListener('mousemove', handleSeekMouseMove, true);
document.addEventListener('mouseup', handleSeekMouseUp, true);
setDragging(true);
audioRef.current?.pause();
handleSeekMouseMove(e.nativeEvent);
e.preventDefault();
e.stopPropagation();
},
[setDragging, spring, resumeAudio],
);
const handleMouseEnter = useCallback(() => {
setHovered(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setHovered(false);
}, HOVER_FADE_DELAY);
}, [setHovered]);
const handleMouseMove = useCallback(() => {
setHovered(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setHovered(false);
}, HOVER_FADE_DELAY);
}, [setHovered]);
const handleMouseLeave = useCallback(() => {
setHovered(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}, [setHovered]);
const handleTouchEnd = useCallback(() => {
setHovered(true);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
hoverTimeoutRef.current = setTimeout(() => {
setHovered(false);
}, HOVER_FADE_DELAY);
}, [setHovered]);
const handleLoadedData = useCallback(() => {
if (!audioRef.current) {
return;
}
setDuration(audioRef.current.duration);
if (typeof startTime !== 'undefined') {
audioRef.current.currentTime = startTime;
}
if (typeof startVolume !== 'undefined') {
audioRef.current.volume = startVolume;
}
if (typeof startMuted !== 'undefined') {
audioRef.current.muted = startMuted;
}
if (startPlaying) {
void audioRef.current.play();
}
}, [setDuration, startTime, startVolume, startMuted, startPlaying]);
const seekBy = (time: number) => {
if (!audioRef.current) {
return;
}
const newTime = audioRef.current.currentTime + time;
if (!isNaN(newTime)) {
audioRef.current.currentTime = newTime;
}
};
const handleAudioKeyDown = useCallback(
(e: React.KeyboardEvent) => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
togglePlay();
}
},
[togglePlay],
);
const handleSkipBackward = useCallback(() => {
seekBy(-5);
}, []);
const handleSkipForward = useCallback(() => {
seekBy(5);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const updateVolumeBy = (step: number) => {
if (!audioRef.current) {
return;
}
const newVolume = audioRef.current.volume + step;
if (!isNaN(newVolume)) {
audioRef.current.volume = newVolume;
audioRef.current.muted = newVolume > 0 ? false : true;
}
};
switch (e.key) {
case 'k':
case ' ':
e.preventDefault();
e.stopPropagation();
togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
toggleMute();
break;
case 'j':
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
seekBy(-5);
break;
case 'l':
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
seekBy(5);
break;
case 'ArrowUp':
e.preventDefault();
e.stopPropagation();
updateVolumeBy(0.15);
break;
case 'ArrowDown':
e.preventDefault();
e.stopPropagation();
updateVolumeBy(-0.15);
break;
}
},
[togglePlay, toggleMute],
);
const springForBand0 = useSpring({
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand1 = useSpring({
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
config: config.wobbly,
});
const springForBand2 = useSpring({
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
config: config.wobbly,
});
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
const effectivelyMuted = muted || volume === 0;
return (
<div
className={classNames('audio-player', { inactive: !revealed })}
ref={playerRef}
style={
{
'--player-background-color': backgroundColor,
'--player-foreground-color': foregroundColor,
'--player-accent-color': accentColor,
} as React.CSSProperties
}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onTouchEnd={handleTouchEnd}
role='button'
tabIndex={0}
onKeyDownCapture={handleKeyDown}
aria-label={alt}
lang={lang}
>
{blurhash && (
<Blurhash
hash={blurhash}
className={classNames('media-gallery__preview', {
'media-gallery__preview--hidden': revealed,
})}
dummy={!useBlurhash}
/>
)}
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
src={src}
ref={handleAudioRef}
preload={startPlaying ? 'auto' : 'none'}
onPlay={handlePlay}
onPause={handlePause}
onProgress={handleProgress}
onLoadedData={handleLoadedData}
onTimeUpdate={handleTimeUpdate}
onVolumeChange={handleVolumeChange}
crossOrigin='anonymous'
/>
<div
className='video-player__seek'
aria-valuemin={0}
aria-valuenow={progress}
aria-valuemax={100}
onMouseDown={handleSeekMouseDown}
onKeyDownCapture={handleAudioKeyDown}
ref={seekRef}
role='slider'
tabIndex={0}
>
<animated.div
className='video-player__seek__buffer'
style={{ width: style.buffer }}
/>
<animated.div
className='video-player__seek__progress'
style={{ width: style.progress }}
/>
<animated.span
className={classNames('video-player__seek__handle', {
active: dragging,
})}
style={{ left: style.progress }}
/>
</div>
<div className='audio-player__controls'>
<div className='audio-player__controls__play'>
<button
type='button'
title={intl.formatMessage(messages.skipBackward)}
aria-label={intl.formatMessage(messages.skipBackward)}
className='player-button'
onClick={handleSkipBackward}
>
<Icon id='' icon={Replay5Icon} />
</button>
</div>
<div className='audio-player__controls__play'>
<svg
className='audio-player__visualizer'
viewBox='0 0 124 124'
xmlns='http://www.w3.org/2000/svg'
>
<animated.circle
opacity={0.5}
cx={57}
cy={62.5}
r={springForBand0.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={65}
cy={57.5}
r={springForBand1.r}
fill='var(--player-accent-color)'
/>
<animated.circle
opacity={0.5}
cx={63}
cy={66.5}
r={springForBand2.r}
fill='var(--player-accent-color)'
/>
<g clipPath={`url(#${accessibilityId}-clip)`}>
<rect
x={14}
y={14}
width={96}
height={96}
fill={`url(#${accessibilityId}-pattern)`}
/>
<rect
x={14}
y={14}
width={96}
height={96}
fill='var(--player-background-color'
opacity={0.45}
/>
</g>
<defs>
<pattern
id={`${accessibilityId}-pattern`}
patternContentUnits='objectBoundingBox'
width='1'
height='1'
>
<use href={`#${accessibilityId}-image`} />
</pattern>
<clipPath id={`${accessibilityId}-clip`}>
<rect
x={14}
y={14}
width={96}
height={96}
rx={48}
fill='white'
/>
</clipPath>
<image
id={`${accessibilityId}-image`}
href={poster}
width={1}
height={1}
preserveAspectRatio='none'
/>
</defs>
</svg>
<button
type='button'
title={intl.formatMessage(paused ? messages.play : messages.pause)}
aria-label={intl.formatMessage(
paused ? messages.play : messages.pause,
)}
className='player-button'
onClick={togglePlay}
>
<Icon
id={paused ? 'play' : 'pause'}
icon={paused ? PlayArrowIcon : PauseIcon}
/>
</button>
</div>
<div className='audio-player__controls__play'>
<button
type='button'
title={intl.formatMessage(messages.skipForward)}
aria-label={intl.formatMessage(messages.skipForward)}
className='player-button'
onClick={handleSkipForward}
>
<Icon id='' icon={Forward5Icon} />
</button>
</div>
</div>
<SpoilerButton
hidden={revealed || editable}
sensitive={sensitive ?? false}
onClick={toggleReveal}
matchedFilters={matchedFilters}
/>
<div
className={classNames('video-player__controls', { active: hovered })}
>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button
type='button'
title={intl.formatMessage(
muted ? messages.unmute : messages.mute,
)}
aria-label={intl.formatMessage(
muted ? messages.unmute : messages.mute,
)}
className='player-button'
onClick={toggleMute}
>
<Icon
id={muted ? 'volume-off' : 'volume-up'}
icon={muted ? VolumeOffIcon : VolumeUpIcon}
/>
</button>
<div
className='video-player__volume active'
ref={volumeRef}
onMouseDown={handleVolumeMouseDown}
role='slider'
aria-valuemin={0}
aria-valuenow={effectivelyMuted ? 0 : volume * 100}
aria-valuemax={100}
tabIndex={0}
>
<animated.div
className='video-player__volume__current'
style={{ width: style.volume }}
/>
<animated.span
className={classNames('video-player__volume__handle')}
style={{ left: style.volume }}
/>
</div>
<span className='video-player__time'>
<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(Math.floor(loadedDuration))}
</span>
</span>
</div>
<div className='video-player__buttons right'>
{!editable && (
<>
<button
type='button'
className='player-button'
onClick={toggleReveal}
>
<FormattedMessage
id='media_gallery.hide'
defaultMessage='Hide'
/>
</button>
<a
title={intl.formatMessage(messages.download)}
aria-label={intl.formatMessage(messages.download)}
className='video-player__download__icon player-button'
href={src}
download
>
<Icon id='download' icon={DownloadIcon} />
</a>
</>
)}
</div>
</div>
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default Audio;

View file

@ -1,136 +0,0 @@
/*
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
const hex2rgba = (hex, alpha = 1) => {
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export default class Visualizer {
constructor (tickSize) {
this.tickSize = tickSize;
}
setCanvas(canvas) {
this.canvas = canvas;
if (canvas) {
this.context = canvas.getContext('2d');
}
}
setAudioContext(context, source) {
const analyser = context.createAnalyser();
analyser.smoothingTimeConstant = 0.6;
analyser.fftSize = 2048;
source.connect(analyser);
this.analyser = analyser;
}
getTickPoints (count) {
const coords = [];
for(let i = 0; i < count; i++) {
const rad = Math.PI * 2 * i / count;
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
}
return coords;
}
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
const dx1 = Math.ceil(cx + x1);
const dy1 = Math.ceil(cy + y1);
const dx2 = Math.ceil(cx + x2);
const dy2 = Math.ceil(cy + y2);
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
const lastColor = hex2rgba(mainColor, 0);
gradient.addColorStop(0, mainColor);
gradient.addColorStop(0.6, mainColor);
gradient.addColorStop(1, lastColor);
this.context.beginPath();
this.context.strokeStyle = gradient;
this.context.lineWidth = 2;
this.context.moveTo(dx1, dy1);
this.context.lineTo(dx2, dy2);
this.context.stroke();
}
getTicks (count, size, radius, scaleCoefficient) {
const ticks = this.getTickPoints(count);
const lesser = 200;
const m = [];
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
const frequencyData = new Uint8Array(bufferLength);
const allScales = [];
if (this.analyser) {
this.analyser.getByteFrequencyData(frequencyData);
}
ticks.forEach((tick, i) => {
const coef = 1 - i / (ticks.length * 2.5);
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
if (delta < 0) {
delta = 0;
}
const k = radius / (radius - (size + delta));
const x1 = tick.x * (radius - size);
const y1 = tick.y * (radius - size);
const x2 = x1 * k;
const y2 = y1 * k;
m.push({ x1, y1, x2, y2 });
if (i < 20) {
let scale = delta / (200 * scaleCoefficient);
scale = scale < 1 ? 1 : scale;
allScales.push(scale);
}
});
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
return m.map(({ x1, y1, x2, y2 }) => ({
x1: x1,
y1: y1,
x2: x2 * scale,
y2: y2 * scale,
}));
}
clear (width, height) {
this.context.clearRect(0, 0, width, height);
}
draw (cx, cy, color, radius, coefficient) {
this.context.save();
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
ticks.forEach(tick => {
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
});
this.context.restore();
}
}

View file

@ -129,7 +129,6 @@ const BookmarkCategoryAdder: React.FC<{
const bookmark_categories = useAppSelector((state) => const bookmark_categories = useAppSelector((state) =>
getOrderedBookmarkCategories(state), getOrderedBookmarkCategories(state),
); );
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return
const status = useAppSelector((state) => state.statuses.get(statusId)); const status = useAppSelector((state) => state.statuses.get(statusId));
const [bookmark_categoryIds, setBookmarkCategoryIds] = useState<string[]>( const [bookmark_categoryIds, setBookmarkCategoryIds] = useState<string[]>(
[] as string[], [] as string[],

View file

@ -13,7 +13,6 @@ import { Avatar } from 'mastodon/components/avatar';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { DisplayName } from 'mastodon/components/display_name'; import { DisplayName } from 'mastodon/components/display_name';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import type { Status } from 'mastodon/models/status';
import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { EmbeddedStatusContent } from './embedded_status_content'; import { EmbeddedStatusContent } from './embedded_status_content';
@ -27,9 +26,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
const clickCoordinatesRef = useRef<[number, number] | null>(); const clickCoordinatesRef = useRef<[number, number] | null>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const status = useAppSelector( const status = useAppSelector((state) => state.statuses.get(statusId));
(state) => state.statuses.get(statusId) as Status | undefined,
);
const account = useAppSelector((state) => const account = useAppSelector((state) =>
state.accounts.get(status?.get('account') as string), state.accounts.get(status?.get('account') as string),

View file

@ -6,7 +6,6 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
import { me } from 'mastodon/initial_state'; import { me } from 'mastodon/initial_state';
import type { NotificationGroupMention } from 'mastodon/models/notification_group'; import type { NotificationGroupMention } from 'mastodon/models/notification_group';
import type { Status } from 'mastodon/models/status';
import { useAppSelector } from 'mastodon/store'; import { useAppSelector } from 'mastodon/store';
import type { LabelRenderer } from './notification_group_with_status'; import type { LabelRenderer } from './notification_group_with_status';
@ -40,7 +39,7 @@ export const NotificationMention: React.FC<{
}> = ({ notification, unread }) => { }> = ({ notification, unread }) => {
const [isDirect, isReply] = useAppSelector((state) => { const [isDirect, isReply] = useAppSelector((state) => {
const status = notification.statusId const status = notification.statusId
? (state.statuses.get(notification.statusId) as Status | undefined) ? state.statuses.get(notification.statusId)
: undefined; : undefined;
if (!status) return [false, false] as const; if (!status) return [false, false] as const;

View file

@ -1,195 +0,0 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import { makeGetStatus } from 'mastodon/selectors';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
open: { id: 'status.open', defaultMessage: 'Expand this status' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, { statusId }) => ({
status: getStatus(state, { id: statusId }),
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
});
return mapStateToProps;
};
class Footer extends ImmutablePureComponent {
static propTypes = {
identity: identityContextPropShape,
statusId: PropTypes.string.isRequired,
status: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
askReplyConfirmation: PropTypes.bool,
withOpenButton: PropTypes.bool,
onClose: PropTypes.func,
...WithRouterPropTypes,
};
_performReply = () => {
const { dispatch, status, onClose } = this.props;
if (onClose) {
onClose(true);
}
dispatch(replyCompose(status));
};
handleReplyClick = () => {
const { dispatch, askReplyConfirmation, status, onClose } = this.props;
const { signedIn } = this.props.identity;
if (signedIn) {
if (askReplyConfirmation) {
onClose(true);
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
} else {
this._performReply();
}
} else {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reply',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
}
};
handleFavouriteClick = () => {
const { dispatch, status } = this.props;
const { signedIn } = this.props.identity;
if (signedIn) {
dispatch(toggleFavourite(status.get('id')));
} else {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'favourite',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
}
};
handleReblogClick = e => {
const { dispatch, status } = this.props;
const { signedIn } = this.props.identity;
if (signedIn) {
dispatch(toggleReblog(status.get('id'), e && e.shiftKey));
} else {
dispatch(openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}));
}
};
handleOpenClick = e => {
const { status, onClose, history } = this.props;
if (e.button !== 0 || !history) {
return;
}
if (onClose) {
onClose();
}
this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
};
render () {
const { status, intl, withOpenButton } = this.props;
const publicStatus = ['public', 'unlisted', 'public_unlisted', 'login'].includes(status.get('visibility_ex'));
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility_ex') === 'private';
let replyIcon, replyIconComponent, replyTitle;
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyIconComponent = ReplyIcon;
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyIconComponent = ReplyAllIcon;
replyTitle = intl.formatMessage(messages.replyAll);
}
let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
reblogIconComponent = RepeatPrivateIcon;
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
reblogIconComponent = RepeatDisabledIcon;
}
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
return (
<div className='picture-in-picture__footer'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={StarIcon} onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' iconComponent={OpenInNewIcon} onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} />}
</div>
);
}
}
export default connect(makeMapStateToProps)(withIdentity(withRouter(injectIntl(Footer))));

View file

@ -0,0 +1,258 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button';
import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Boost with original visibility',
},
cancel_reblog_private: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
cannot_reblog: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
removeFavourite: {
id: 'status.remove_favourite',
defaultMessage: 'Remove from favorites',
},
open: { id: 'status.open', defaultMessage: 'Expand this status' },
});
export const Footer: React.FC<{
statusId: string;
withOpenButton?: boolean;
onClose: (arg0?: boolean) => void;
}> = ({ statusId, withOpenButton, onClose }) => {
const { signedIn } = useIdentity();
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const status = useAppSelector((state) => state.statuses.get(statusId));
const accountId = status?.get('account') as string | undefined;
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const askReplyConfirmation = useAppSelector(
(state) => (state.compose.get('text') as string).trim().length !== 0,
);
const handleReplyClick = useCallback(() => {
if (!status) {
return;
}
if (signedIn) {
onClose(true);
if (askReplyConfirmation) {
dispatch(
openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }),
);
} else {
dispatch(replyCompose(status));
}
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reply',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
}, [dispatch, status, signedIn, askReplyConfirmation, onClose]);
const handleFavouriteClick = useCallback(() => {
if (!status) {
return;
}
if (signedIn) {
dispatch(toggleFavourite(status.get('id')));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'favourite',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
}, [dispatch, status, signedIn]);
const handleReblogClick = useCallback(
(e: React.MouseEvent) => {
if (!status) {
return;
}
if (signedIn) {
dispatch(toggleReblog(status.get('id'), e.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, signedIn],
);
const handleOpenClick = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || !status) {
return;
}
onClose();
history.push(`/@${account?.acct}/${status.get('id') as string}`);
},
[history, status, account, onClose],
);
if (!status) {
return null;
}
const publicStatus = [
'public',
'unlisted',
'login',
'public_unlisted',
].includes(status.get('visibility_ex') as string);
const reblogPrivate =
status.getIn(['account', 'id']) === me &&
status.get('visibility_ex') === 'private';
let replyIcon, replyIconComponent, replyTitle;
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyIconComponent = ReplyIcon;
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyIconComponent = ReplyAllIcon;
replyTitle = intl.formatMessage(messages.replyAll);
}
let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus
? RepeatActiveIcon
: RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
reblogIconComponent = RepeatPrivateIcon;
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
reblogIconComponent = RepeatDisabledIcon;
}
const favouriteTitle = intl.formatMessage(
status.get('favourited') ? messages.removeFavourite : messages.favourite,
);
return (
<div className='picture-in-picture__footer'>
<IconButton
className='status__action-bar-button'
title={replyTitle}
icon={
status.get('in_reply_to_account_id') ===
status.getIn(['account', 'id'])
? 'reply'
: replyIcon
}
iconComponent={
status.get('in_reply_to_account_id') ===
status.getIn(['account', 'id'])
? ReplyIcon
: replyIconComponent
}
onClick={handleReplyClick}
counter={status.get('replies_count') as number}
/>
<IconButton
className={classNames('status__action-bar-button', { reblogPrivate })}
disabled={!publicStatus && !reblogPrivate}
active={status.get('reblogged') as boolean}
title={reblogTitle}
icon='retweet'
iconComponent={reblogIconComponent}
onClick={handleReblogClick}
counter={status.get('reblogs_count') as number}
/>
<IconButton
className='status__action-bar-button star-icon'
animate
active={status.get('favourited') as boolean}
title={favouriteTitle}
icon='star'
iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon}
onClick={handleFavouriteClick}
counter={status.get('favourites_count') as number}
/>
{withOpenButton && (
<IconButton
className='status__action-bar-button'
title={intl.formatMessage(messages.open)}
icon='external-link'
iconComponent={OpenInNewIcon}
onClick={handleOpenClick}
href={`/@${account?.acct}/${status.get('id') as string}`}
/>
)}
</div>
);
};

View file

@ -1,11 +1,11 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture'; import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import Audio from 'mastodon/features/audio'; import { Audio } from 'mastodon/features/audio';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions'; import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
import Footer from './components/footer'; import { Footer } from './components/footer';
import { Header } from './components/header'; import { Header } from './components/header';
export const PictureInPicture: React.FC = () => { export const PictureInPicture: React.FC = () => {
@ -58,14 +58,14 @@ export const PictureInPicture: React.FC = () => {
player = ( player = (
<Audio <Audio
src={src} src={src}
currentTime={currentTime} startTime={currentTime}
volume={volume} startVolume={volume}
muted={muted} startMuted={muted}
startPlaying
poster={poster} poster={poster}
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
foregroundColor={foregroundColor} foregroundColor={foregroundColor}
accentColor={accentColor} accentColor={accentColor}
autoPlay
/> />
); );
} }
@ -76,7 +76,7 @@ export const PictureInPicture: React.FC = () => {
{player} {player}
<Footer statusId={statusId} /> <Footer statusId={statusId} onClose={handleClose} />
</div> </div>
); );
}; };

View file

@ -13,7 +13,9 @@ import { Link } from 'react-router-dom';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react'; import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import { AnimatedNumber } from 'mastodon/components/animated_number'; import { AnimatedNumber } from 'mastodon/components/animated_number';
import { Avatar } from 'mastodon/components/avatar';
import { ContentWarning } from 'mastodon/components/content_warning'; import { ContentWarning } from 'mastodon/components/content_warning';
import { DisplayName } from 'mastodon/components/display_name';
import { EditedTimestamp } from 'mastodon/components/edited_timestamp'; import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
import { FilterWarning } from 'mastodon/components/filter_warning'; import { FilterWarning } from 'mastodon/components/filter_warning';
import { FormattedDateWrapper } from 'mastodon/components/formatted_date'; import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
@ -21,20 +23,17 @@ import type { StatusLike } from 'mastodon/components/hashtag_bar';
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar'; import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconLogo } from 'mastodon/components/logo'; import { IconLogo } from 'mastodon/components/logo';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; import MediaGallery from 'mastodon/components/media_gallery';
import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
import { SearchabilityIcon } from 'mastodon/components/searchability_icon'; import { SearchabilityIcon } from 'mastodon/components/searchability_icon';
import StatusContent from 'mastodon/components/status_content';
import StatusEmojiReactionsBar from 'mastodon/components/status_emoji_reactions_bar';
import { VisibilityIcon } from 'mastodon/components/visibility_icon'; import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Audio } from 'mastodon/features/audio';
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { enableEmojiReaction, isHideItem } from 'mastodon/initial_state'; import { enableEmojiReaction, isHideItem } from 'mastodon/initial_state';
import { Avatar } from '../../../components/avatar';
import { DisplayName } from '../../../components/display_name';
import MediaGallery from '../../../components/media_gallery';
import StatusContent from '../../../components/status_content';
import StatusEmojiReactionsBar from '../../../components/status_emoji_reactions_bar';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import Card from './card'; import Card from './card';
interface VideoModalOptions { interface VideoModalOptions {
@ -198,18 +197,17 @@ export const DetailedStatus: React.FC<{
src={attachment.get('url')} src={attachment.get('url')}
alt={description} alt={description}
lang={language} lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={ poster={
attachment.get('preview_url') || attachment.get('preview_url') ||
status.getIn(['account', 'avatar_static']) status.getIn(['account', 'avatar_static'])
} }
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
accentColor={attachment.getIn(['meta', 'colors', 'accent'])} accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
visible={showMedia} visible={showMedia}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}
height={150}
onToggleVisibility={onToggleMediaVisibility} onToggleVisibility={onToggleMediaVisibility}
matchedFilters={status.get('matched_media_filters')} matchedFilters={status.get('matched_media_filters')}
/> />

View file

@ -1,74 +0,0 @@
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { getAverageFromBlurhash } from 'mastodon/blurhash';
import Audio from 'mastodon/features/audio';
import Footer from 'mastodon/features/picture_in_picture/components/footer';
const mapStateToProps = (state, { statusId }) => ({
status: state.getIn(['statuses', statusId]),
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
});
class AudioModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
statusId: PropTypes.string.isRequired,
status: ImmutablePropTypes.map.isRequired,
accountStaticAvatar: PropTypes.string.isRequired,
options: PropTypes.shape({
autoPlay: PropTypes.bool,
}),
onClose: PropTypes.func.isRequired,
onChangeBackgroundColor: PropTypes.func.isRequired,
};
componentDidMount () {
const { media, onChangeBackgroundColor } = this.props;
const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
onChangeBackgroundColor(backgroundColor || { r: 255, g: 255, b: 255 });
}
componentWillUnmount () {
this.props.onChangeBackgroundColor(null);
}
render () {
const { media, status, accountStaticAvatar, onClose } = this.props;
const options = this.props.options || {};
const language = status.getIn(['translation', 'language']) || status.get('language');
const description = media.getIn(['translation', 'description']) || media.get('description');
return (
<div className='modal-root__modal audio-modal'>
<div className='audio-modal__container'>
<Audio
src={media.get('url')}
alt={description}
lang={language}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
poster={media.get('preview_url') || accountStaticAvatar}
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
accentColor={media.getIn(['meta', 'colors', 'accent'])}
autoPlay={options.autoPlay}
/>
</div>
<div className='media-modal__overlay'>
{status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
</div>
</div>
);
}
}
export default connect(mapStateToProps, null, null, { forwardRef: true })(AudioModal);

View file

@ -0,0 +1,78 @@
import { useEffect } from 'react';
import { getAverageFromBlurhash } from 'mastodon/blurhash';
import type { RGB } from 'mastodon/blurhash';
import { Audio } from 'mastodon/features/audio';
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
import type { MediaAttachment } from 'mastodon/models/media_attachment';
import { useAppSelector } from 'mastodon/store';
const AudioModal: React.FC<{
media: MediaAttachment;
statusId: string;
options: {
autoPlay: boolean;
};
onClose: () => void;
onChangeBackgroundColor: (color: RGB | null) => void;
}> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => {
const status = useAppSelector((state) => state.statuses.get(statusId));
const accountId = status?.get('account') as string | undefined;
const accountStaticAvatar = useAppSelector((state) =>
accountId ? state.accounts.get(accountId)?.avatar_static : undefined,
);
useEffect(() => {
const backgroundColor = getAverageFromBlurhash(
media.get('blurhash') as string | null,
);
onChangeBackgroundColor(backgroundColor ?? { r: 255, g: 255, b: 255 });
return () => {
onChangeBackgroundColor(null);
};
}, [media, onChangeBackgroundColor]);
const language = (status?.getIn(['translation', 'language']) ??
status?.get('language')) as string;
const description = (media.getIn(['translation', 'description']) ??
media.get('description')) as string;
return (
<div className='modal-root__modal audio-modal'>
<div className='audio-modal__container'>
<Audio
src={media.get('url') as string}
alt={description}
lang={language}
poster={
(media.get('preview_url') as string | null) ?? accountStaticAvatar
}
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
backgroundColor={
media.getIn(['meta', 'colors', 'background']) as string
}
foregroundColor={
media.getIn(['meta', 'colors', 'foreground']) as string
}
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
startPlaying={options.autoPlay}
/>
</div>
<div className='media-modal__overlay'>
{status && (
<Footer
statusId={status.get('id') as string}
withOpenButton
onClose={onClose}
/>
)}
</div>
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default AudioModal;

View file

@ -18,7 +18,7 @@ import { getAverageFromBlurhash } from 'mastodon/blurhash';
import { GIFV } from 'mastodon/components/gifv'; import { GIFV } from 'mastodon/components/gifv';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import Footer from 'mastodon/features/picture_in_picture/components/footer'; import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { disableSwiping } from 'mastodon/initial_state'; import { disableSwiping } from 'mastodon/initial_state';

View file

@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { getAverageFromBlurhash } from 'mastodon/blurhash'; import { getAverageFromBlurhash } from 'mastodon/blurhash';
import Footer from 'mastodon/features/picture_in_picture/components/footer'; import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({

View file

@ -806,7 +806,7 @@ export const Video: React.FC<{
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen // The outer wrapper is necessary to avoid reflowing the layout when going into full screen
return ( return (
<div> <div>
<div <div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
role='menuitem' role='menuitem'
className={classNames('video-player', { className={classNames('video-player', {
inactive: !revealed, inactive: !revealed,
@ -820,7 +820,7 @@ export const Video: React.FC<{
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onClick={handleClickRoot} onClick={handleClickRoot}
onKeyDown={handleKeyDown} onKeyDownCapture={handleKeyDown}
tabIndex={0} tabIndex={0}
> >
{blurhash && ( {blurhash && (
@ -845,7 +845,7 @@ export const Video: React.FC<{
title={alt} title={alt}
lang={lang} lang={lang}
onClick={handleClick} onClick={handleClick}
onKeyDown={handleVideoKeyDown} onKeyDownCapture={handleVideoKeyDown}
onPlay={handlePlay} onPlay={handlePlay}
onPause={handlePause} onPause={handlePause}
onLoadedData={handleLoadedData} onLoadedData={handleLoadedData}

View file

@ -0,0 +1,112 @@
import { useState, useEffect, useRef, useCallback } from 'react';
const normalizeFrequencies = (arr: Float32Array): number[] => {
return new Array(...arr).map((value: number) => {
if (value === -Infinity) {
return 0;
}
return Math.sqrt(1 - (Math.max(-100, Math.min(-10, value)) * -1) / 100);
});
};
export const useAudioVisualizer = (
ref: React.MutableRefObject<HTMLAudioElement | null>,
numBands: number,
) => {
const audioContextRef = useRef<AudioContext>();
const sourceRef = useRef<MediaElementAudioSourceNode>();
const analyzerRef = useRef<AnalyserNode>();
const [frequencyBands, setFrequencyBands] = useState<number[]>(
new Array(numBands).fill(0),
);
useEffect(() => {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
analyzerRef.current = audioContextRef.current.createAnalyser();
analyzerRef.current.smoothingTimeConstant = 0.6;
analyzerRef.current.fftSize = 2048;
}
return () => {
if (audioContextRef.current) {
void audioContextRef.current.close();
}
};
}, []);
useEffect(() => {
if (
audioContextRef.current &&
analyzerRef.current &&
!sourceRef.current &&
ref.current
) {
sourceRef.current = audioContextRef.current.createMediaElementSource(
ref.current,
);
sourceRef.current.connect(analyzerRef.current);
sourceRef.current.connect(audioContextRef.current.destination);
}
return () => {
if (sourceRef.current) {
sourceRef.current.disconnect();
}
};
}, [ref]);
useEffect(() => {
const source = sourceRef.current;
const analyzer = analyzerRef.current;
const context = audioContextRef.current;
if (!source || !analyzer || !context) {
return;
}
const bufferLength = analyzer.frequencyBinCount;
const frequencyData = new Float32Array(bufferLength);
const updateProgress = () => {
analyzer.getFloatFrequencyData(frequencyData);
const normalizedFrequencies = normalizeFrequencies(
frequencyData.slice(100, 600),
);
const bands: number[] = [];
const chunkSize = Math.ceil(normalizedFrequencies.length / numBands);
for (let i = 0; i < numBands; i++) {
const sum = normalizedFrequencies
.slice(i * chunkSize, (i + 1) * chunkSize)
.reduce((sum, cur) => sum + cur, 0);
bands.push(sum / chunkSize);
}
setFrequencyBands(bands);
};
const updateInterval = setInterval(updateProgress, 15);
return () => {
clearInterval(updateInterval);
};
}, [numBands]);
const resume = useCallback(() => {
if (audioContextRef.current) {
void audioContextRef.current.resume();
}
}, []);
const suspend = useCallback(() => {
if (audioContextRef.current) {
void audioContextRef.current.suspend();
}
}, []);
return [resume, suspend, frequencyBands] as const;
};

View file

@ -29,6 +29,7 @@
"account.enable_notifications": "Advisér mig, når @{name} poster", "account.enable_notifications": "Advisér mig, når @{name} poster",
"account.endorse": "Fremhæv på profil", "account.endorse": "Fremhæv på profil",
"account.featured": "Fremhævet", "account.featured": "Fremhævet",
"account.featured.accounts": "Profiler",
"account.featured.hashtags": "Hashtags", "account.featured.hashtags": "Hashtags",
"account.featured.posts": "Indlæg", "account.featured.posts": "Indlæg",
"account.featured_tags.last_status_at": "Seneste indlæg {date}", "account.featured_tags.last_status_at": "Seneste indlæg {date}",

View file

@ -429,7 +429,7 @@
"home.show_announcements": "Ankündigungen anzeigen", "home.show_announcements": "Ankündigungen anzeigen",
"ignore_notifications_modal.disclaimer": "Mastodon kann anderen Nutzer*innen nicht mitteilen, dass du deren Benachrichtigungen ignorierst. Das Ignorieren von Benachrichtigungen wird nicht das Absenden der Nachricht selbst unterbinden.", "ignore_notifications_modal.disclaimer": "Mastodon kann anderen Nutzer*innen nicht mitteilen, dass du deren Benachrichtigungen ignorierst. Das Ignorieren von Benachrichtigungen wird nicht das Absenden der Nachricht selbst unterbinden.",
"ignore_notifications_modal.filter_instead": "Stattdessen filtern", "ignore_notifications_modal.filter_instead": "Stattdessen filtern",
"ignore_notifications_modal.filter_to_act_users": "Du wirst weiterhin die Möglichkeit haben, andere Nutzer*innen zu genehmigen, abzulehnen oder zu melden", "ignore_notifications_modal.filter_to_act_users": "Du wirst weiterhin die Möglichkeit haben, andere Nutzer*innen zu akzeptieren, abzulehnen oder zu melden",
"ignore_notifications_modal.filter_to_avoid_confusion": "Filtern hilft, mögliches Durcheinander zu vermeiden", "ignore_notifications_modal.filter_to_avoid_confusion": "Filtern hilft, mögliches Durcheinander zu vermeiden",
"ignore_notifications_modal.filter_to_review_separately": "Gefilterte Benachrichtigungen können separat überprüft werden", "ignore_notifications_modal.filter_to_review_separately": "Gefilterte Benachrichtigungen können separat überprüft werden",
"ignore_notifications_modal.ignore": "Benachrichtigungen ignorieren", "ignore_notifications_modal.ignore": "Benachrichtigungen ignorieren",
@ -613,11 +613,11 @@
"notification.relationships_severance_event.user_domain_block": "Du hast {target} blockiert {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst, wurden entfernt.", "notification.relationships_severance_event.user_domain_block": "Du hast {target} blockiert {followersCount} deiner Follower und {followingCount, plural, one {# Konto, dem} other {# Konten, denen}} du folgst, wurden entfernt.",
"notification.status": "{name} postete …", "notification.status": "{name} postete …",
"notification.update": "{name} bearbeitete einen Beitrag", "notification.update": "{name} bearbeitete einen Beitrag",
"notification_requests.accept": "Genehmigen", "notification_requests.accept": "Akzeptieren",
"notification_requests.accept_multiple": "{count, plural, one {# Anfrage genehmigen …} other {# Anfragen genehmigen …}}", "notification_requests.accept_multiple": "{count, plural, one {# Anfrage akzeptieren …} other {# Anfragen akzeptieren …}}",
"notification_requests.confirm_accept_multiple.button": "{count, plural, one {Anfrage genehmigen} other {Anfragen genehmigen}}", "notification_requests.confirm_accept_multiple.button": "{count, plural, one {Anfrage akzeptieren} other {Anfragen akzeptieren}}",
"notification_requests.confirm_accept_multiple.message": "Du bist dabei, {{count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} zu genehmigen. Möchtest du wirklich fortfahren?", "notification_requests.confirm_accept_multiple.message": "Du bist dabei, {{count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} zu akzeptieren. Möchtest du wirklich fortfahren?",
"notification_requests.confirm_accept_multiple.title": "Benachrichtigungsanfragen genehmigen?", "notification_requests.confirm_accept_multiple.title": "Benachrichtigungsanfragen akzeptieren?",
"notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Anfrage ablehnen} other {Anfragen ablehnen}}", "notification_requests.confirm_dismiss_multiple.button": "{count, plural, one {Anfrage ablehnen} other {Anfragen ablehnen}}",
"notification_requests.confirm_dismiss_multiple.message": "Du bist dabei, {count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} abzulehnen. Du wirst nicht mehr ohne Weiteres auf {count, plural, one {sie} other {sie}} zugreifen können. Möchtest du wirklich fortfahren?", "notification_requests.confirm_dismiss_multiple.message": "Du bist dabei, {count, plural, one {eine Benachrichtigungsanfrage} other {# Benachrichtigungsanfragen}} abzulehnen. Du wirst nicht mehr ohne Weiteres auf {count, plural, one {sie} other {sie}} zugreifen können. Möchtest du wirklich fortfahren?",
"notification_requests.confirm_dismiss_multiple.title": "Benachrichtigungsanfragen ablehnen?", "notification_requests.confirm_dismiss_multiple.title": "Benachrichtigungsanfragen ablehnen?",

View file

@ -584,7 +584,7 @@
"notification.favourite_pm": "{name} ha marcado como favorita tu mención privada", "notification.favourite_pm": "{name} ha marcado como favorita tu mención privada",
"notification.favourite_pm.name_and_others_with_link": "{name} y <a>{count, plural, one {# más} other {# más}}</a> han marcado como favorita tu mención privada", "notification.favourite_pm.name_and_others_with_link": "{name} y <a>{count, plural, one {# más} other {# más}}</a> han marcado como favorita tu mención privada",
"notification.follow": "{name} te empezó a seguir", "notification.follow": "{name} te empezó a seguir",
"notification.follow.name_and_others": "{name} y <a>{count, plural, one {# otro} other {# otros}}</a> te siguieron", "notification.follow.name_and_others": "{name} y <a>{count, plural, one {# otro} other {otros #}}</a> te siguieron",
"notification.follow_request": "{name} ha solicitado seguirte", "notification.follow_request": "{name} ha solicitado seguirte",
"notification.follow_request.name_and_others": "{name} y {count, plural, one {# más} other {# más}} han solicitado seguirte", "notification.follow_request.name_and_others": "{name} y {count, plural, one {# más} other {# más}} han solicitado seguirte",
"notification.label.mention": "Mención", "notification.label.mention": "Mención",

View file

@ -24,6 +24,7 @@
"account.edit_profile": "Ẓreg amaɣnu", "account.edit_profile": "Ẓreg amaɣnu",
"account.enable_notifications": "Azen-iyi-d ilɣa mi ara d-isuffeɣ @{name}", "account.enable_notifications": "Azen-iyi-d ilɣa mi ara d-isuffeɣ @{name}",
"account.endorse": "Welleh fell-as deg umaɣnu-inek", "account.endorse": "Welleh fell-as deg umaɣnu-inek",
"account.featured.accounts": "Imeɣna",
"account.featured.hashtags": "Ihacṭagen", "account.featured.hashtags": "Ihacṭagen",
"account.featured.posts": "Tisuffaɣ", "account.featured.posts": "Tisuffaɣ",
"account.featured_tags.last_status_at": "Tasuffeɣt taneggarut ass n {date}", "account.featured_tags.last_status_at": "Tasuffeɣt taneggarut ass n {date}",
@ -36,6 +37,7 @@
"account.following": "Yeṭṭafaṛ", "account.following": "Yeṭṭafaṛ",
"account.following_counter": "{count, plural, one {{counter} yettwaḍfaren} other {{counter} yettwaḍfaren}}", "account.following_counter": "{count, plural, one {{counter} yettwaḍfaren} other {{counter} yettwaḍfaren}}",
"account.follows.empty": "Ar tura, amseqdac-agi ur yeṭṭafaṛ yiwen.", "account.follows.empty": "Ar tura, amseqdac-agi ur yeṭṭafaṛ yiwen.",
"account.follows_you": "Yeṭṭafaṛ-ik·em-id",
"account.go_to_profile": "Ddu ɣer umaɣnu", "account.go_to_profile": "Ddu ɣer umaɣnu",
"account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}", "account.hide_reblogs": "Ffer ayen i ibeṭṭu @{name}",
"account.joined_short": "Izeddi da seg ass n", "account.joined_short": "Izeddi da seg ass n",
@ -415,6 +417,7 @@
"not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.", "not_signed_in_indicator.not_signed_in": "You need to sign in to access this resource.",
"notification.admin.report": "Yemla-t-id {name} {target}", "notification.admin.report": "Yemla-t-id {name} {target}",
"notification.admin.sign_up": "Ijerred {name}", "notification.admin.sign_up": "Ijerred {name}",
"notification.annual_report.view": "Wali #Wrapstodon",
"notification.favourite": "{name} yesmenyaf addad-ik·im", "notification.favourite": "{name} yesmenyaf addad-ik·im",
"notification.follow": "iṭṭafar-ik·em-id {name}", "notification.follow": "iṭṭafar-ik·em-id {name}",
"notification.follow.name_and_others": "{name} akked <a>{count, plural, one {# nniḍen} other {# nniḍen}}</a> iḍfeṛ-k·m-id", "notification.follow.name_and_others": "{name} akked <a>{count, plural, one {# nniḍen} other {# nniḍen}}</a> iḍfeṛ-k·m-id",
@ -466,6 +469,7 @@
"notifications.group": "{count} n yilɣa", "notifications.group": "{count} n yilɣa",
"notifications.mark_as_read": "Creḍ akk ilɣa am wakken ttwaɣran", "notifications.mark_as_read": "Creḍ akk ilɣa am wakken ttwaɣran",
"notifications.permission_denied": "D awezɣi ad yili wermad n yilɣa n tnarit axateṛ turagt tettwagdel", "notifications.permission_denied": "D awezɣi ad yili wermad n yilɣa n tnarit axateṛ turagt tettwagdel",
"notifications.policy.accept": "Qbel",
"notifications.policy.drop": "Anef-as", "notifications.policy.drop": "Anef-as",
"notifications.policy.filter": "Sizdeg", "notifications.policy.filter": "Sizdeg",
"notifications.policy.filter_new_accounts.hint": "Imiḍanen imaynuten i d-yennulfan deg {days, plural, one {yiwen n wass} other {# n wussan}} yezrin", "notifications.policy.filter_new_accounts.hint": "Imiḍanen imaynuten i d-yennulfan deg {days, plural, one {yiwen n wass} other {# n wussan}} yezrin",
@ -580,6 +584,7 @@
"search_results.all": "Akk", "search_results.all": "Akk",
"search_results.hashtags": "Ihacṭagen", "search_results.hashtags": "Ihacṭagen",
"search_results.no_results": "Ulac igemmaḍ.", "search_results.no_results": "Ulac igemmaḍ.",
"search_results.no_search_yet": "Ɛreḍ ad d-tnadiḍ ɣef iznan, imaɣnuten neɣ ihacṭagen.",
"search_results.see_all": "Wali-ten akk", "search_results.see_all": "Wali-ten akk",
"search_results.statuses": "Tisuffaɣ", "search_results.statuses": "Tisuffaɣ",
"search_results.title": "Igemmaḍ n unadi ɣef \"{q}\"", "search_results.title": "Igemmaḍ n unadi ɣef \"{q}\"",

View file

@ -29,6 +29,7 @@
"account.enable_notifications": "@{name} 의 게시물 알림 켜기", "account.enable_notifications": "@{name} 의 게시물 알림 켜기",
"account.endorse": "프로필에 추천하기", "account.endorse": "프로필에 추천하기",
"account.featured": "추천", "account.featured": "추천",
"account.featured.accounts": "프로필",
"account.featured.hashtags": "해시태그", "account.featured.hashtags": "해시태그",
"account.featured.posts": "게시물", "account.featured.posts": "게시물",
"account.featured_tags.last_status_at": "{date}에 마지막으로 게시", "account.featured_tags.last_status_at": "{date}에 마지막으로 게시",

View file

@ -19,14 +19,17 @@
"account.block_domain": "Blokuoti serverį {domain}", "account.block_domain": "Blokuoti serverį {domain}",
"account.block_short": "Blokuoti", "account.block_short": "Blokuoti",
"account.blocked": "Užblokuota", "account.blocked": "Užblokuota",
"account.blocking": "Blokavimas",
"account.cancel_follow_request": "Atšaukti sekimą", "account.cancel_follow_request": "Atšaukti sekimą",
"account.copy": "Kopijuoti nuorodą į profilį", "account.copy": "Kopijuoti nuorodą į profilį",
"account.direct": "Privačiai paminėti @{name}", "account.direct": "Privačiai paminėti @{name}",
"account.disable_notifications": "Nustoti man pranešti, kai @{name} paskelbia", "account.disable_notifications": "Nustoti man pranešti, kai @{name} paskelbia",
"account.domain_blocking": "Blokuoti domeną",
"account.edit_profile": "Redaguoti profilį", "account.edit_profile": "Redaguoti profilį",
"account.enable_notifications": "Pranešti man, kai @{name} paskelbia", "account.enable_notifications": "Pranešti man, kai @{name} paskelbia",
"account.endorse": "Rodyti profilyje", "account.endorse": "Rodyti profilyje",
"account.featured": "Rodomi", "account.featured": "Rodomi",
"account.featured.accounts": "Profiliai",
"account.featured.hashtags": "Saitažodžiai", "account.featured.hashtags": "Saitažodžiai",
"account.featured.posts": "Įrašai", "account.featured.posts": "Įrašai",
"account.featured_tags.last_status_at": "Paskutinis įrašas {date}", "account.featured_tags.last_status_at": "Paskutinis įrašas {date}",
@ -39,6 +42,7 @@
"account.following": "Sekama", "account.following": "Sekama",
"account.following_counter": "{count, plural, one {{counter} sekimas} few {{counter} sekimai} many {{counter} sekimo} other {{counter} sekimų}}", "account.following_counter": "{count, plural, one {{counter} sekimas} few {{counter} sekimai} many {{counter} sekimo} other {{counter} sekimų}}",
"account.follows.empty": "Šis naudotojas dar nieko neseka.", "account.follows.empty": "Šis naudotojas dar nieko neseka.",
"account.follows_you": "Seka tave",
"account.go_to_profile": "Eiti į profilį", "account.go_to_profile": "Eiti į profilį",
"account.hide_reblogs": "Slėpti pasidalinimus iš @{name}", "account.hide_reblogs": "Slėpti pasidalinimus iš @{name}",
"account.in_memoriam": "Atminimui.", "account.in_memoriam": "Atminimui.",
@ -53,6 +57,8 @@
"account.mute_notifications_short": "Nutildyti pranešimus", "account.mute_notifications_short": "Nutildyti pranešimus",
"account.mute_short": "Nutildyti", "account.mute_short": "Nutildyti",
"account.muted": "Nutildytas", "account.muted": "Nutildytas",
"account.muting": "Užtildymas",
"account.mutual": "Jūs sekate vienas kitą",
"account.no_bio": "Nėra pateikto aprašymo.", "account.no_bio": "Nėra pateikto aprašymo.",
"account.open_original_page": "Atidaryti originalų puslapį", "account.open_original_page": "Atidaryti originalų puslapį",
"account.posts": "Įrašai", "account.posts": "Įrašai",
@ -61,6 +67,7 @@
"account.report": "Pranešti apie @{name}", "account.report": "Pranešti apie @{name}",
"account.requested": "Laukiama patvirtinimo. Spustelėk, kad atšauktum sekimo prašymą", "account.requested": "Laukiama patvirtinimo. Spustelėk, kad atšauktum sekimo prašymą",
"account.requested_follow": "{name} paprašė tave sekti", "account.requested_follow": "{name} paprašė tave sekti",
"account.requests_to_follow_you": "Prašymai sekti jus",
"account.share": "Bendrinti @{name} profilį", "account.share": "Bendrinti @{name} profilį",
"account.show_reblogs": "Rodyti pasidalinimus iš @{name}", "account.show_reblogs": "Rodyti pasidalinimus iš @{name}",
"account.statuses_counter": "{count, plural, one {{counter} įrašas} few {{counter} įrašai} many {{counter} įrašo} other {{counter} įrašų}}", "account.statuses_counter": "{count, plural, one {{counter} įrašas} few {{counter} įrašai} many {{counter} įrašo} other {{counter} įrašų}}",
@ -255,6 +262,7 @@
"disabled_account_banner.text": "Tavo paskyra {disabledAccount} šiuo metu išjungta.", "disabled_account_banner.text": "Tavo paskyra {disabledAccount} šiuo metu išjungta.",
"dismissable_banner.community_timeline": "Tai naujausi vieši įrašai iš žmonių, kurių paskyros talpinamos {domain}.", "dismissable_banner.community_timeline": "Tai naujausi vieši įrašai iš žmonių, kurių paskyros talpinamos {domain}.",
"dismissable_banner.dismiss": "Atmesti", "dismissable_banner.dismiss": "Atmesti",
"dismissable_banner.explore_links": "Šiomis naujienų istorijomis šiandien \"Fediverse\" dalijamasi dažniausiai. Naujesnės istorijos, kurias paskelbė daugiau skirtingų žmonių, užima aukštesnę vietą.",
"domain_block_modal.block": "Blokuoti serverį", "domain_block_modal.block": "Blokuoti serverį",
"domain_block_modal.block_account_instead": "Blokuoti @{name} vietoj to", "domain_block_modal.block_account_instead": "Blokuoti @{name} vietoj to",
"domain_block_modal.they_can_interact_with_old_posts": "Žmonės iš šio serverio gali bendrauti su tavo senomis įrašomis.", "domain_block_modal.they_can_interact_with_old_posts": "Žmonės iš šio serverio gali bendrauti su tavo senomis įrašomis.",

View file

@ -26,6 +26,7 @@
"account.edit_profile": "Labot profilu", "account.edit_profile": "Labot profilu",
"account.enable_notifications": "Paziņot man, kad @{name} izveido ierakstu", "account.enable_notifications": "Paziņot man, kad @{name} izveido ierakstu",
"account.endorse": "Izcelts profilā", "account.endorse": "Izcelts profilā",
"account.featured.accounts": "Profili",
"account.featured.hashtags": "Tēmturi", "account.featured.hashtags": "Tēmturi",
"account.featured.posts": "Ieraksti", "account.featured.posts": "Ieraksti",
"account.featured_tags.last_status_at": "Pēdējais ieraksts {date}", "account.featured_tags.last_status_at": "Pēdējais ieraksts {date}",
@ -369,7 +370,9 @@
"hashtag.counter_by_accounts": "{count, plural, zero{{counter} dalībnieku} one {{counter} dalībnieks} other {{counter} dalībnieki}}", "hashtag.counter_by_accounts": "{count, plural, zero{{counter} dalībnieku} one {{counter} dalībnieks} other {{counter} dalībnieki}}",
"hashtag.counter_by_uses": "{count, plural, zero {{counter} ierakstu} one {{counter} ieraksts} other {{counter} ieraksti}}", "hashtag.counter_by_uses": "{count, plural, zero {{counter} ierakstu} one {{counter} ieraksts} other {{counter} ieraksti}}",
"hashtag.counter_by_uses_today": "{count, plural, zero {{counter} ierakstu} one {{counter} ieraksts} other {{counter} ieraksti}} šodien", "hashtag.counter_by_uses_today": "{count, plural, zero {{counter} ierakstu} one {{counter} ieraksts} other {{counter} ieraksti}} šodien",
"hashtag.feature": "Attēlot profilā",
"hashtag.follow": "Sekot tēmturim", "hashtag.follow": "Sekot tēmturim",
"hashtag.unfeature": "Neattēlot profilā",
"hashtag.unfollow": "Pārstāt sekot tēmturim", "hashtag.unfollow": "Pārstāt sekot tēmturim",
"hashtags.and_other": "… un {count, plural, other {vēl #}}", "hashtags.and_other": "… un {count, plural, other {vēl #}}",
"hints.profiles.see_more_followers": "Skatīt vairāk sekotāju {domain}", "hints.profiles.see_more_followers": "Skatīt vairāk sekotāju {domain}",

View file

@ -29,6 +29,7 @@
"account.enable_notifications": "Varsle meg når @{name} skriv innlegg", "account.enable_notifications": "Varsle meg når @{name} skriv innlegg",
"account.endorse": "Vis på profilen", "account.endorse": "Vis på profilen",
"account.featured": "Utvald", "account.featured": "Utvald",
"account.featured.accounts": "Profilar",
"account.featured.hashtags": "Emneknaggar", "account.featured.hashtags": "Emneknaggar",
"account.featured.posts": "Innlegg", "account.featured.posts": "Innlegg",
"account.featured_tags.last_status_at": "Sist nytta {date}", "account.featured_tags.last_status_at": "Sist nytta {date}",
@ -168,6 +169,7 @@
"column.lists": "Lister", "column.lists": "Lister",
"column.mutes": "Målbundne brukarar", "column.mutes": "Målbundne brukarar",
"column.notifications": "Varsel", "column.notifications": "Varsel",
"column.pins": "Utvalde innlegg",
"column.public": "Samla tidsline", "column.public": "Samla tidsline",
"column_back_button.label": "Attende", "column_back_button.label": "Attende",
"column_header.hide_settings": "Gøym innstillingane", "column_header.hide_settings": "Gøym innstillingane",
@ -303,9 +305,9 @@
"emoji_button.search_results": "Søkeresultat", "emoji_button.search_results": "Søkeresultat",
"emoji_button.symbols": "Symbol", "emoji_button.symbols": "Symbol",
"emoji_button.travel": "Reise & stader", "emoji_button.travel": "Reise & stader",
"empty_column.account_featured.me": "Du har ikkje framheva noko enno. Visste du at du kan framheva innlegg, merkelappar du bruker mykje, og til og med venekontoar på profilen din?", "empty_column.account_featured.me": "Du har ikkje valt ut noko enno. Visste du at du kan velja ut innlegg, merkelappar du bruker mykje, og til og med venekontoar på profilen din?",
"empty_column.account_featured.other": "{acct} har ikkje framheva noko enno. Visste du at du kan framheva innlegg, merkelappar du bruker mykje, og til og med venekontoar på profilen din?", "empty_column.account_featured.other": "{acct} har ikkje valt ut noko enno. Visste du at du kan velja ut innlegg, merkelappar du bruker mykje, og til og med venekontoar på profilen din?",
"empty_column.account_featured_other.unknown": "Denne kontoen har ikkje framheva noko enno.", "empty_column.account_featured_other.unknown": "Denne kontoen har ikkje valt ut noko enno.",
"empty_column.account_hides_collections": "Denne brukaren har valt å ikkje gjere denne informasjonen tilgjengeleg", "empty_column.account_hides_collections": "Denne brukaren har valt å ikkje gjere denne informasjonen tilgjengeleg",
"empty_column.account_suspended": "Kontoen er utestengd", "empty_column.account_suspended": "Kontoen er utestengd",
"empty_column.account_timeline": "Ingen tut her!", "empty_column.account_timeline": "Ingen tut her!",
@ -478,6 +480,7 @@
"keyboard_shortcuts.my_profile": "Opne profilen din", "keyboard_shortcuts.my_profile": "Opne profilen din",
"keyboard_shortcuts.notifications": "Opne varselkolonna", "keyboard_shortcuts.notifications": "Opne varselkolonna",
"keyboard_shortcuts.open_media": "Opne media", "keyboard_shortcuts.open_media": "Opne media",
"keyboard_shortcuts.pinned": "Opne lista over utvalde innlegg",
"keyboard_shortcuts.profile": "Opne forfattaren sin profil", "keyboard_shortcuts.profile": "Opne forfattaren sin profil",
"keyboard_shortcuts.reply": "Svar på innlegg", "keyboard_shortcuts.reply": "Svar på innlegg",
"keyboard_shortcuts.requests": "Opne lista med fylgjeførespurnader", "keyboard_shortcuts.requests": "Opne lista med fylgjeførespurnader",
@ -561,6 +564,7 @@
"navigation_bar.mutes": "Målbundne brukarar", "navigation_bar.mutes": "Målbundne brukarar",
"navigation_bar.opened_in_classic_interface": "Innlegg, kontoar, og enkelte andre sider blir opna som standard i det klassiske webgrensesnittet.", "navigation_bar.opened_in_classic_interface": "Innlegg, kontoar, og enkelte andre sider blir opna som standard i det klassiske webgrensesnittet.",
"navigation_bar.personal": "Personleg", "navigation_bar.personal": "Personleg",
"navigation_bar.pins": "Utvalde innlegg",
"navigation_bar.preferences": "Innstillingar", "navigation_bar.preferences": "Innstillingar",
"navigation_bar.public_timeline": "Føderert tidsline", "navigation_bar.public_timeline": "Føderert tidsline",
"navigation_bar.search": "Søk", "navigation_bar.search": "Søk",
@ -856,6 +860,7 @@
"status.mute": "Demp @{name}", "status.mute": "Demp @{name}",
"status.mute_conversation": "Demp samtale", "status.mute_conversation": "Demp samtale",
"status.open": "Utvid denne statusen", "status.open": "Utvid denne statusen",
"status.pin": "Vis på profilen",
"status.read_more": "Les meir", "status.read_more": "Les meir",
"status.reblog": "Framhev", "status.reblog": "Framhev",
"status.reblog_private": "Framhev til dei originale mottakarane", "status.reblog_private": "Framhev til dei originale mottakarane",
@ -880,6 +885,7 @@
"status.translated_from_with": "Omsett frå {lang} ved bruk av {provider}", "status.translated_from_with": "Omsett frå {lang} ved bruk av {provider}",
"status.uncached_media_warning": "Førehandsvisning er ikkje tilgjengeleg", "status.uncached_media_warning": "Førehandsvisning er ikkje tilgjengeleg",
"status.unmute_conversation": "Opphev demping av samtalen", "status.unmute_conversation": "Opphev demping av samtalen",
"status.unpin": "Ikkje vis på profilen",
"subscribed_languages.lead": "Kun innlegg på valde språk vil bli dukke opp i heimestraumen din og i listene dine etter denne endringa. For å motta innlegg på alle språk, la vere å velje nokon.", "subscribed_languages.lead": "Kun innlegg på valde språk vil bli dukke opp i heimestraumen din og i listene dine etter denne endringa. For å motta innlegg på alle språk, la vere å velje nokon.",
"subscribed_languages.save": "Lagre endringar", "subscribed_languages.save": "Lagre endringar",
"subscribed_languages.target": "Endre abonnerte språk for {target}", "subscribed_languages.target": "Endre abonnerte språk for {target}",

View file

@ -26,6 +26,8 @@
"account.edit_profile": "Upraviť profil", "account.edit_profile": "Upraviť profil",
"account.enable_notifications": "Zapnúť upozornenia na príspevky od @{name}", "account.enable_notifications": "Zapnúť upozornenia na príspevky od @{name}",
"account.endorse": "Zobraziť na vlastnom profile", "account.endorse": "Zobraziť na vlastnom profile",
"account.featured.accounts": "Profily",
"account.featured.hashtags": "Hashtagy",
"account.featured.posts": "Príspevky", "account.featured.posts": "Príspevky",
"account.featured_tags.last_status_at": "Posledný príspevok dňa {date}", "account.featured_tags.last_status_at": "Posledný príspevok dňa {date}",
"account.featured_tags.last_status_never": "Žiadne príspevky", "account.featured_tags.last_status_never": "Žiadne príspevky",
@ -37,6 +39,7 @@
"account.following": "Sledovaný účet", "account.following": "Sledovaný účet",
"account.following_counter": "{count, plural, one {{counter} sledovaných} other {{counter} sledovaných}}", "account.following_counter": "{count, plural, one {{counter} sledovaných} other {{counter} sledovaných}}",
"account.follows.empty": "Tento účet ešte nikoho nesleduje.", "account.follows.empty": "Tento účet ešte nikoho nesleduje.",
"account.follows_you": "Nasleduje ťa",
"account.go_to_profile": "Prejsť na profil", "account.go_to_profile": "Prejsť na profil",
"account.hide_reblogs": "Skryť zdieľania od @{name}", "account.hide_reblogs": "Skryť zdieľania od @{name}",
"account.in_memoriam": "In memoriam.", "account.in_memoriam": "In memoriam.",
@ -208,6 +211,7 @@
"confirmations.redraft.confirm": "Vymazať a prepísať", "confirmations.redraft.confirm": "Vymazať a prepísať",
"confirmations.redraft.message": "Určite chcete tento príspevok vymazať a prepísať? Prídete o jeho zdieľania a ohviezdičkovania a odpovede na pôvodný príspevok budú odlúčené.", "confirmations.redraft.message": "Určite chcete tento príspevok vymazať a prepísať? Prídete o jeho zdieľania a ohviezdičkovania a odpovede na pôvodný príspevok budú odlúčené.",
"confirmations.redraft.title": "Vymazať a prepísať príspevok?", "confirmations.redraft.title": "Vymazať a prepísať príspevok?",
"confirmations.remove_from_followers.confirm": "Odstrániť nasledovateľa",
"confirmations.reply.confirm": "Odpovedať", "confirmations.reply.confirm": "Odpovedať",
"confirmations.reply.message": "Odpovedaním akurát teraz prepíšeš správu, ktorú máš práve rozpísanú. Si si istý/á, že chceš pokračovať?", "confirmations.reply.message": "Odpovedaním akurát teraz prepíšeš správu, ktorú máš práve rozpísanú. Si si istý/á, že chceš pokračovať?",
"confirmations.reply.title": "Prepísať príspevok?", "confirmations.reply.title": "Prepísať príspevok?",

View file

@ -27,6 +27,7 @@
"account.enable_notifications": "Повідомляти мене про дописи @{name}", "account.enable_notifications": "Повідомляти мене про дописи @{name}",
"account.endorse": "Рекомендувати у моєму профілі", "account.endorse": "Рекомендувати у моєму профілі",
"account.featured": "Рекомендоване", "account.featured": "Рекомендоване",
"account.featured.accounts": "Профілі",
"account.featured.hashtags": "Хештеги", "account.featured.hashtags": "Хештеги",
"account.featured.posts": "Дописи", "account.featured.posts": "Дописи",
"account.featured_tags.last_status_at": "Останній допис {date}", "account.featured_tags.last_status_at": "Останній допис {date}",

View file

@ -528,7 +528,7 @@
"lists.replies_policy.none": "沒有人", "lists.replies_policy.none": "沒有人",
"lists.save": "儲存", "lists.save": "儲存",
"lists.search": "搜尋", "lists.search": "搜尋",
"lists.show_replies_to": "包含來自列表成員的回覆", "lists.show_replies_to": "包含來自列表成員的回覆",
"load_pending": "{count, plural, other {# 個新項目}}", "load_pending": "{count, plural, other {# 個新項目}}",
"loading_indicator.label": "正在載入...", "loading_indicator.label": "正在載入...",
"media_gallery.hide": "隱藏", "media_gallery.hide": "隱藏",

View file

@ -176,5 +176,10 @@ export function createAccountFromServerJSON(serverJSON: ApiAccountJSON) {
), ),
note_emojified: emojify(accountJSON.note, emojiMap), note_emojified: emojify(accountJSON.note, emojiMap),
note_plain: unescapeHTML(accountJSON.note), note_plain: unescapeHTML(accountJSON.note),
url:
accountJSON.url.startsWith('http://') ||
accountJSON.url.startsWith('https://')
? accountJSON.url
: accountJSON.uri,
}); });
} }

View file

@ -96,7 +96,8 @@ const statusTranslateUndo = (state, id) => {
}); });
}; };
/** @type {ImmutableMap<string, ImmutableMap<string, any>>} */
/** @type {ImmutableMap<string, import('mastodon/models/status').Status>} */
const initialState = ImmutableMap(); const initialState = ImmutableMap();
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */ /** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m683-300 57-57-124-123h104v-80H480v240h80v-103l123 123ZM80-600v-200h280v200H80Zm0 80h360v-280h360q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160q-33 0-56.5-23.5T80-240v-280Z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-160q-33 0-56.5-23.5T80-240v-280h80v280h640v-480H440v-80h360q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm523-140 57-57-124-123h104v-80H480v240h80v-103l123 123ZM80-600v-200h280v200H80Zm400 120Z"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View file

@ -7294,15 +7294,69 @@ a.status-card {
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
background: var(--background-color); background: var(--player-background-color, var(--background-color));
color: var(--player-foreground-color);
border-radius: 8px; border-radius: 8px;
padding-bottom: 44px; padding-bottom: 44px;
width: 100%; width: 100%;
outline: 1px solid var(--media-outline-color); outline: 1px solid var(--media-outline-color);
outline-offset: -1px; outline-offset: -1px;
aspect-ratio: 16 / 9;
container: audio-player / inline-size;
&__controls {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
height: 100%;
&__play {
display: flex;
align-items: center;
justify-content: center;
position: relative;
.player-button {
position: absolute;
top: 50%;
inset-inline-start: 50%;
transform: translate(-50%, -50%);
}
.icon {
filter: var(--overlay-icon-shadow);
}
}
.player-button {
display: inline-block;
outline: 0;
padding: 5px;
flex: 0 0 auto;
background: transparent;
border: 0;
color: var(--player-foreground-color);
opacity: 0.75;
.icon {
width: 48px;
height: 48px;
}
&:active,
&:hover,
&:focus {
opacity: 1;
}
}
}
&__visualizer {
max-width: 200px;
}
&.inactive { &.inactive {
audio, .video-player__seek,
.audio-player__controls,
.video-player__controls { .video-player__controls {
visibility: hidden; visibility: hidden;
} }
@ -7319,6 +7373,13 @@ a.status-card {
opacity: 0.2; opacity: 0.2;
} }
.video-player__seek__progress,
.video-player__seek__handle,
.video-player__volume__current,
.video-player__volume__handle {
background-color: var(--player-accent-color);
}
.video-player__buttons button, .video-player__buttons button,
.video-player__buttons a { .video-player__buttons a {
color: currentColor; color: currentColor;
@ -7338,6 +7399,13 @@ a.status-card {
color: currentColor; color: currentColor;
} }
@container audio-player (max-width: 400px) {
.video-player__time,
.player-button.video-player__download__icon {
display: none;
}
}
.video-player__seek::before, .video-player__seek::before,
.video-player__seek__buffer, .video-player__seek__buffer,
.video-player__seek__progress { .video-player__seek__progress {
@ -7405,10 +7473,12 @@ a.status-card {
); );
padding: 0 15px; padding: 0 15px;
opacity: 0; opacity: 0;
pointer-events: none;
transition: opacity 0.1s ease; transition: opacity 0.1s ease;
&.active { &.active {
opacity: 1; opacity: 1;
pointer-events: auto;
} }
} }
@ -7494,6 +7564,7 @@ a.status-card {
background: transparent; background: transparent;
border: 0; border: 0;
color: rgba($white, 0.75); color: rgba($white, 0.75);
font-weight: 500;
&:active, &:active,
&:hover, &:hover,
@ -8957,23 +9028,33 @@ noscript {
bottom: 20px; bottom: 20px;
inset-inline-end: 20px; inset-inline-end: 20px;
width: 300px; width: 300px;
box-shadow: var(--dropdown-shadow);
&__footer { &__footer {
border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px;
background: lighten($ui-base-color, 4%); background: var(--modal-background-variant-color);
padding: 10px; backdrop-filter: var(--background-filter);
padding-top: 12px; border: 1px solid var(--modal-border-color);
border-top: 0;
padding: 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
&__header { &__header {
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
background: lighten($ui-base-color, 4%); background: var(--modal-background-variant-color);
padding: 10px; backdrop-filter: var(--background-filter);
border: 1px solid var(--modal-border-color);
border-bottom: 0;
padding: 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.icon-button {
padding: 6px;
}
&__account { &__account {
display: flex; display: flex;
text-decoration: none; text-decoration: none;
@ -8981,7 +9062,7 @@ noscript {
} }
.account__avatar { .account__avatar {
margin-inline-end: 10px; margin-inline-end: 8px;
} }
.display-name { .display-name {
@ -9008,30 +9089,36 @@ noscript {
} }
.picture-in-picture-placeholder { .picture-in-picture-placeholder {
border-radius: 8px;
box-sizing: border-box; box-sizing: border-box;
border: 2px dashed var(--background-border-color); border: 1px dashed var(--background-border-color);
background: $base-shadow-color;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 10px; margin-top: 16px;
font-size: 16px; font-size: 15px;
line-height: 21px;
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
color: $darker-text-color; color: $dark-text-color;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
.icon { .icon {
width: 24px; width: 48px;
height: 24px; height: 48px;
margin-bottom: 10px; margin-bottom: 8px;
} }
&:hover, &:hover,
&:focus, &:active,
&:active { &:focus {
border-color: lighten($ui-base-color, 12%); color: $darker-text-color;
}
&:focus-visible {
outline: $ui-button-focus-outline;
border-color: transparent;
} }
} }

View file

@ -20,7 +20,7 @@
--on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.5)}; --on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.5)};
--avatar-border-radius: 8px; --avatar-border-radius: 8px;
--media-outline-color: #{rgba(#fcf8ff, 0.15)}; --media-outline-color: #{rgba(#fcf8ff, 0.15)};
--overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.25)}); --overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.35)});
--error-background-color: #{darken($error-red, 16%)}; --error-background-color: #{darken($error-red, 16%)};
--error-active-background-color: #{darken($error-red, 12%)}; --error-active-background-color: #{darken($error-red, 12%)};
--on-error-color: #fff; --on-error-color: #fff;

View file

@ -275,7 +275,9 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@quote.status = status @quote.status = status
@quote.save @quote.save
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, request_id: @options[:request_id])
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @json['context'])
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, prefetched_quoted_object: embedded_quote, request_id: @options[:request_id])
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] }) ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
end end

View file

@ -15,13 +15,15 @@ class ActivityPub::Parser::MediaAttachmentParser
end end
def remote_url def remote_url
Addressable::URI.parse(@json['url'])&.normalize&.to_s url = Addressable::URI.parse(@json['url'])&.normalize&.to_s
url unless unsupported_uri_scheme?(url)
rescue Addressable::URI::InvalidURIError rescue Addressable::URI::InvalidURIError
nil nil
end end
def thumbnail_remote_url def thumbnail_remote_url
Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s url = Addressable::URI.parse(@json['icon'].is_a?(Hash) ? @json['icon']['url'] : @json['icon'])&.normalize&.to_s
url unless unsupported_uri_scheme?(url)
rescue Addressable::URI::InvalidURIError rescue Addressable::URI::InvalidURIError
nil nil
end end

View file

@ -34,7 +34,10 @@ class ActivityPub::Parser::StatusParser
end end
def url def url
url_to_href(@object['url'], 'text/html') if @object['url'].present? return if @object['url'].blank?
url = url_to_href(@object['url'], 'text/html')
url unless unsupported_uri_scheme?(url)
end end
def text def text
@ -129,11 +132,11 @@ class ActivityPub::Parser::StatusParser
end end
def favourites_count def favourites_count
@object.dig(:likes, :totalItems) @object['likes']['totalItems'] if @object.is_a?(Hash) && @object['likes'].is_a?(Hash)
end end
def reblogs_count def reblogs_count
@object.dig(:shares, :totalItems) @object['shares']['totalItems'] if @object.is_a?(Hash) && @object['shares'].is_a?(Hash)
end end
def quote_policy def quote_policy
@ -154,6 +157,11 @@ class ActivityPub::Parser::StatusParser
end.first end.first
end end
# The inlined quote; out of the attributes we support, only `https://w3id.org/fep/044f#quote` explicitly supports inlined objects
def quoted_object
as_array(@object['quote']).first
end
def quote_approval_uri def quote_approval_uri
as_array(@object['quoteAuthorization']).first as_array(@object['quoteAuthorization']).first
end end

View file

@ -4,6 +4,7 @@ require 'singleton'
class ActivityPub::TagManager class ActivityPub::TagManager
include Singleton include Singleton
include JsonLdHelper
include RoutingHelper include RoutingHelper
CONTEXT = 'https://www.w3.org/ns/activitystreams' CONTEXT = 'https://www.w3.org/ns/activitystreams'
@ -17,7 +18,7 @@ class ActivityPub::TagManager
end end
def url_for(target) def url_for(target)
return target.url if target.respond_to?(:local?) && !target.local? return unsupported_uri_scheme?(target.url) ? nil : target.url if target.respond_to?(:local?) && !target.local?
return unless target.respond_to?(:object_type) return unless target.respond_to?(:object_type)

View file

@ -6,14 +6,13 @@
class HttpSignatureDraft class HttpSignatureDraft
REQUEST_TARGET = '(request-target)' REQUEST_TARGET = '(request-target)'
def initialize(keypair, key_id, full_path: true) def initialize(keypair, key_id)
@keypair = keypair @keypair = keypair
@key_id = key_id @key_id = key_id
@full_path = full_path
end end
def request_target(verb, url) def request_target(verb, url)
if url.query.nil? || !@full_path if url.query.nil?
"#{verb} #{url.path}" "#{verb} #{url.path}"
else else
"#{verb} #{url.path}?#{url.query}" "#{verb} #{url.path}?#{url.query}"

View file

@ -75,7 +75,6 @@ class Request
@url = Addressable::URI.parse(url).normalize @url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client) @http_client = options.delete(:http_client)
@allow_local = options.delete(:allow_local) @allow_local = options.delete(:allow_local)
@full_path = !options.delete(:omit_query_string)
@options = { @options = {
follow: { follow: {
max_hops: 3, max_hops: 3,
@ -102,7 +101,7 @@ class Request
key_id = ActivityPub::TagManager.instance.key_uri_for(actor) key_id = ActivityPub::TagManager.instance.key_uri_for(actor)
keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : actor.keypair keypair = sign_with.present? ? OpenSSL::PKey::RSA.new(sign_with) : actor.keypair
@signing = HttpSignatureDraft.new(keypair, key_id, full_path: @full_path) @signing = HttpSignatureDraft.new(keypair, key_id)
self self
end end

View file

@ -57,20 +57,7 @@ class ActivityPub::FetchRepliesService < BaseService
return unless @allow_synchronous_requests return unless @allow_synchronous_requests
return if non_matching_uri_hosts?(@reference_uri, collection_or_uri) return if non_matching_uri_hosts?(@reference_uri, collection_or_uri)
# NOTE: For backward compatibility reasons, Mastodon signs outgoing
# queries incorrectly by default.
#
# While this is relevant for all URLs with query strings, this is
# the only code path where this happens in practice.
#
# Therefore, retry with correct signatures if this fails.
begin
fetch_resource_without_id_validation(collection_or_uri, nil, raise_on_error: :temporary) fetch_resource_without_id_validation(collection_or_uri, nil, raise_on_error: :temporary)
rescue Mastodon::UnexpectedResponseError => e
raise unless e.response && e.response.code == 401 && Addressable::URI.parse(collection_or_uri).query.present?
fetch_resource_without_id_validation(collection_or_uri, nil, raise_on_error: :temporary, request_options: { omit_query_string: false })
end
end end
def filter_replies(items) def filter_replies(items)

View file

@ -376,7 +376,6 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
def update_quote! def update_quote!
return unless Mastodon::Feature.inbound_quotes_enabled? return unless Mastodon::Feature.inbound_quotes_enabled?
quote = nil
quote_uri = @status_parser.quote_uri quote_uri = @status_parser.quote_uri
if quote_uri.present? if quote_uri.present?
@ -397,21 +396,23 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
quote = Quote.create(status: @status, approval_uri: approval_uri) quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true @quote_changed = true
end end
end
if quote.present?
begin
quote.save quote.save
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, request_id: @request_id)
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS fetch_and_verify_quote!(quote, quote_uri)
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
end
elsif @status.quote.present? elsif @status.quote.present?
@status.quote.destroy! @status.quote.destroy!
@quote_changed = true @quote_changed = true
end end
end end
def fetch_and_verify_quote!(quote, quote_uri)
embedded_quote = safe_prefetched_embed(@account, @status_parser.quoted_object, @activity_json['context'])
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, prefetched_quoted_object: embedded_quote, request_id: @request_id)
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
end
def update_counts! def update_counts!
likes = @status_parser.favourites_count likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count shares = @status_parser.reblogs_count

View file

@ -4,15 +4,15 @@ class ActivityPub::VerifyQuoteService < BaseService
include JsonLdHelper include JsonLdHelper
# Optionally fetch quoted post, and verify the quote is authorized # Optionally fetch quoted post, and verify the quote is authorized
def call(quote, fetchable_quoted_uri: nil, prefetched_body: nil, request_id: nil) def call(quote, fetchable_quoted_uri: nil, prefetched_quoted_object: nil, prefetched_approval: nil, request_id: nil)
@request_id = request_id @request_id = request_id
@quote = quote @quote = quote
@fetching_error = nil @fetching_error = nil
fetch_quoted_post_if_needed!(fetchable_quoted_uri) fetch_quoted_post_if_needed!(fetchable_quoted_uri, prefetched_body: prefetched_quoted_object)
return if fast_track_approval! || quote.approval_uri.blank? return if fast_track_approval! || quote.approval_uri.blank?
@json = fetch_approval_object(quote.approval_uri, prefetched_body:) @json = fetch_approval_object(quote.approval_uri, prefetched_body: prefetched_approval)
return quote.reject! if @json.nil? return quote.reject! if @json.nil?
return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo'])) return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
@ -68,11 +68,11 @@ class ActivityPub::VerifyQuoteService < BaseService
ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject']) ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject'])
end end
def fetch_quoted_post_if_needed!(uri) def fetch_quoted_post_if_needed!(uri, prefetched_body: nil)
return if uri.nil? || @quote.quoted_status.present? return if uri.nil? || @quote.quoted_status.present?
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status) status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id) status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, prefetched_body:, request_id: @request_id)
@quote.update(quoted_status: status) if status.present? @quote.update(quoted_status: status) if status.present?
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
if ENV['REDIS_NAMESPACE']
es_configured = ENV['ES_ENABLED'] == 'true' || ENV.fetch('ES_HOST', 'localhost') != 'localhost' || ENV.fetch('ES_PORT', '9200') != '9200' || ENV.fetch('ES_PASS', 'password') != 'password'
warn <<~MESSAGE
WARNING: the REDIS_NAMESPACE environment variable is deprecated and will be removed in Mastodon 4.4.0.
Please see documentation at https://github.com/mastodon/redis_namespace_migration
MESSAGE
warn <<~MESSAGE if es_configured && !ENV['ES_PREFIX']
In addition, as REDIS_NAMESPACE is being used as a prefix for Elasticsearch, please do not forget to set ES_PREFIX to "#{ENV.fetch('REDIS_NAMESPACE')}".
MESSAGE
end

View file

@ -275,6 +275,10 @@ kab:
ip: Tansa IP ip: Tansa IP
providers: providers:
delete: Kkes delete: Kkes
name: Isem
providers: Asaǧǧaw
registrations:
confirm: Sentem
save: Sekles save: Sekles
title: FASP title: FASP
follow_recommendations: follow_recommendations:
@ -387,6 +391,7 @@ kab:
everyone: Tisirag timezwura everyone: Tisirag timezwura
privileges: privileges:
administrator: Anedbal administrator: Anedbal
manage_settings: Asefrek n iɣewwaṛen
view_dashboard: Timẓriwt n tfelwit view_dashboard: Timẓriwt n tfelwit
rules: rules:
add_new: Rnu alugen add_new: Rnu alugen
@ -451,6 +456,7 @@ kab:
changelog: Amaynut changelog: Amaynut
draft: Arewway draft: Arewway
history: Amazray history: Amazray
live: Srid
publish: Asuffeɣ publish: Asuffeɣ
save_draft: Sekles arewway save_draft: Sekles arewway
title: Tiwtilin n useqdec title: Tiwtilin n useqdec

View file

@ -36,6 +36,25 @@ nan:
approved_msg: 成功審核 %{username} ê註冊申請ah approved_msg: 成功審核 %{username} ê註冊申請ah
are_you_sure: Lí kám確定 are_you_sure: Lí kám確定
avatar: 標頭 avatar: 標頭
by_domain: 域名
change_email:
changed_msg: Email改成功ah
current_email: 現在ê email
label: 改email
new_email: 新ê email
submit: 改email
title: 替 %{username} 改email
change_role:
changed_msg: 角色改成功ah
edit_roles: 管理用者ê角色
label: 改角色
no_role: 無角色
title: 替 %{username} 改角色
confirm: 確認
confirmed: 確認ah
confirming: Teh確認
custom: 自訂
delete: Thâi資料
deleted: Thâi掉ah deleted: Thâi掉ah
demote: 降級 demote: 降級
destroyed_msg: Teh-beh thâi掉 %{username} ê資料 destroyed_msg: Teh-beh thâi掉 %{username} ê資料
@ -49,15 +68,61 @@ nan:
email: 電子phue箱 email: 電子phue箱
email_status: 電子phue ê狀態 email_status: 電子phue ê狀態
enable: 取消冷凍 enable: 取消冷凍
enable_sign_in_token_auth: 啟用電子phue ê token認證
enabled: 啟用ah
enabled_msg: 成功kā %{username} ê口座退冰
followers: 跟tuè lí ê
follows: Lí跟tuè ê
header: 封面ê圖
inbox_url: 收件kheh-á ê URL
invite_request_text: 加入ê理由
invited_by: 邀請ê lâng
ip: IP
joined: 加入ê時
location: location:
all: Kui ê all: Kui ê
local: 本地 local: 本地
remote: 別ê站 remote: 別ê站
title: 位置 title: 位置
login_status: 登入ê狀態
media_attachments: 媒體ê附件
memorialize: 變做故人ê口座
memorialized: 變做故人ê口座ah
memorialized_msg: 成功kā %{username} 變做故人ê口座ah
moderation:
active: 活ê
all: 全部
disabled: 停止使用ah
pending: Teh審核
silenced: 受限制
suspended: 權限中止ah
title: 管理
moderation_notes: 管理ê註釋
most_recent_activity: 最近ê活動時間
most_recent_ip: 最近ê IP
no_account_selected: 因為無揀任何口座所以lóng無改變
no_limits_imposed: 無受著限制
no_role_assigned: 無分著角色
not_subscribed: 無訂
pending: Teh等審核
perform_full_suspension: 中止權限
previous_strikes: Khah早ê處份
remove_avatar: Thâi掉標頭 remove_avatar: Thâi掉標頭
removed_avatar_msg: 成功thâi掉 %{username} ê 標頭影像 removed_avatar_msg: 成功thâi掉 %{username} ê 標頭影像
username: 用者ê名
view_domain: 看域名ê摘要
warn: 警告
web: 網頁
whitelisted: 允准佇聯邦傳資料
action_logs: action_logs:
action_types: action_types:
approve_appeal: 批准投訴
approve_user: 批准用者
assigned_to_self_report: 分配檢舉
change_email_user: 替用者改email
change_role_user: 改用者ê角色
confirm_user: 確認用者
create_account_warning: 建立警告
remove_avatar_user: Thâi掉標頭 remove_avatar_user: Thâi掉標頭
actions: actions:
remove_avatar_user_html: "%{name} thâi掉 %{target} ê標頭" remove_avatar_user_html: "%{name} thâi掉 %{target} ê標頭"

View file

@ -903,6 +903,8 @@ nn:
system_checks: system_checks:
database_schema_check: database_schema_check:
message_html: Det venter på databaseoverføringer. Vennligst kjør disse for å sikre at applikasjonen oppfører seg som forventet message_html: Det venter på databaseoverføringer. Vennligst kjør disse for å sikre at applikasjonen oppfører seg som forventet
elasticsearch_analysis_index_mismatch:
message_html: Indeksanalyseinnstillingane til Elasticsearch er utdaterte. Køyr <code>tootctl search deploy --only-mapping --only=%{value}</code>
elasticsearch_health_red: elasticsearch_health_red:
message_html: Elasticsearch-klynga er usunn (raud status), og søkjefunksjonane er utilgjengelege message_html: Elasticsearch-klynga er usunn (raud status), og søkjefunksjonane er utilgjengelege
elasticsearch_health_yellow: elasticsearch_health_yellow:
@ -1367,8 +1369,8 @@ nn:
featured_tags: featured_tags:
add_new: Legg til ny add_new: Legg til ny
errors: errors:
limit: Du har allereie framheva så mange emneknaggar som det går an å gjera limit: Du har allereie valt ut så mange emneknaggar som det går an å gjera
hint_html: "<strong>Hva er utvalgte emneknagger?</strong> De vises frem tydelig på din offentlige profil, og lar folk bla i dine offentlige innlegg som spesifikt har de emneknaggene. De er et bra verktøy for å holde styr på kreative verk eller langtidsprosjekter." hint_html: "<strong>Vel ut dei viktigaste emneknaggane på profilen din.</strong> Utvalde emneknaggar er eit flott verkty for å halda oversikt over kreativt arbeid og langtidsprosjekt. Dei er lette å sjå på profilen din, og gjev deg rask tilgang til dine eigne innlegg."
filters: filters:
contexts: contexts:
account: Profiler account: Profiler
@ -1805,7 +1807,7 @@ nn:
development: Utvikling development: Utvikling
edit_profile: Endr profil edit_profile: Endr profil
export: Eksporter export: Eksporter
featured_tags: Utvalgte emneknagger featured_tags: Utvalde emneknaggar
import: Hent inn import: Hent inn
import_and_export: Importer og eksporter import_and_export: Importer og eksporter
migrate: Kontoflytting migrate: Kontoflytting

View file

@ -1628,7 +1628,7 @@ zh-TW:
thousand: K thousand: K
trillion: T trillion: T
otp_authentication: otp_authentication:
code_hint: 請輸入您驗證應用程式所產生的代碼以確認 code_hint: 請輸入您驗證應用程式所產生之 token 以確認
description_html: 若您啟用使用驗證應用程式的<strong>兩階段驗證</strong>,您每次登入都需要輸入由您的手機所產生之 Token。 description_html: 若您啟用使用驗證應用程式的<strong>兩階段驗證</strong>,您每次登入都需要輸入由您的手機所產生之 Token。
enable: 啟用 enable: 啟用
instructions_html: "<strong>請用您手機上的 Google Authenticator 或類似的 TOTP 應用程式掃描此 QR code</strong>。從現在開始,該應用程式將會產生您每次登入都必須輸入的 token。" instructions_html: "<strong>請用您手機上的 Google Authenticator 或類似的 TOTP 應用程式掃描此 QR code</strong>。從現在開始,該應用程式將會產生您每次登入都必須輸入的 token。"

View file

@ -59,7 +59,7 @@ services:
web: web:
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
build: . build: .
image: kmyblue:18.0-dev image: kmyblue:19.0-dev
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec puma -C config/puma.rb command: bundle exec puma -C config/puma.rb
@ -83,7 +83,7 @@ services:
build: build:
dockerfile: ./streaming/Dockerfile dockerfile: ./streaming/Dockerfile
context: . context: .
image: kmyblue-streaming:18.0-dev image: kmyblue-streaming:19.0-dev
restart: always restart: always
env_file: .env.production env_file: .env.production
command: node ./streaming/index.js command: node ./streaming/index.js
@ -101,7 +101,7 @@ services:
sidekiq: sidekiq:
build: . build: .
image: kmyblue:18.0-dev image: kmyblue:19.0-dev
restart: always restart: always
env_file: .env.production env_file: .env.production
command: bundle exec sidekiq command: bundle exec sidekiq

View file

@ -35,7 +35,7 @@ module Mastodon
end end
def default_prerelease def default_prerelease
'alpha.4' 'alpha.5'
end end
def prerelease def prerelease

View file

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'vips'
def gen_border(codepoint, color) def gen_border(codepoint, color)
input = Rails.public_path.join('emoji', "#{codepoint}.svg") input = Rails.public_path.join('emoji', "#{codepoint}.svg")
dest = Rails.public_path.join('emoji', "#{codepoint}_border.svg") dest = Rails.public_path.join('emoji', "#{codepoint}_border.svg")
@ -191,6 +189,8 @@ namespace :emojis do
desc 'Generate a spritesheet of emojis' desc 'Generate a spritesheet of emojis'
task :generate_emoji_sheet do task :generate_emoji_sheet do
require 'vips'
src = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_data.json') src = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_data.json')
sheet = Oj.load(File.read(src)) sheet = Oj.load(File.read(src))

View file

@ -21,7 +21,7 @@ RSpec.describe ActivityPub::Activity::Create do
type: 'Create', type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: object_json, object: object_json,
}.with_indifferent_access }.deep_stringify_keys
end end
let(:conversation_hash) do let(:conversation_hash) do
@ -113,7 +113,7 @@ RSpec.describe ActivityPub::Activity::Create do
type: 'Create', type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: json, object: json,
}.with_indifferent_access }.deep_stringify_keys
end end
before do before do
@ -2474,7 +2474,7 @@ RSpec.describe ActivityPub::Activity::Create do
type: 'Create', type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: Addressable::URI.new(scheme: 'bear', query_values: { t: token, u: object_json[:id] }).to_s, object: Addressable::URI.new(scheme: 'bear', query_values: { t: token, u: object_json[:id] }).to_s,
}.with_indifferent_access }.deep_stringify_keys
end end
let(:object_json) do let(:object_json) do

View file

@ -16,7 +16,7 @@ RSpec.describe ActivityPub::Parser::StatusParser do
type: 'Create', type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender), actor: ActivityPub::TagManager.instance.uri_for(sender),
object: object_json, object: object_json,
}.with_indifferent_access }.deep_stringify_keys
end end
let(:object_json) do let(:object_json) do
@ -49,6 +49,24 @@ RSpec.describe ActivityPub::Parser::StatusParser do
) )
end end
context 'when the likes collection is not inlined' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), 'post1'].join('/'),
type: 'Note',
to: 'https://www.w3.org/ns/activitystreams#Public',
content: 'bleh',
published: 1.hour.ago.utc.iso8601,
updated: 1.hour.ago.utc.iso8601,
likes: 'https://example.com/collections/likes',
}
end
it 'does not raise an error' do
expect { subject.favourites_count }.to_not raise_error
end
end
describe '#quote_policy' do describe '#quote_policy' do
subject do subject do
described_class described_class

View file

@ -89,6 +89,37 @@ RSpec.describe ActivityPub::VerifyQuoteService do
end end
end end
context 'with a valid activity for a post that cannot be fetched but is passed as fetched_quoted_object' do
let(:quoted_status) { nil }
let(:approval_interaction_target) { 'https://b.example.com/unknown-quoted' }
let(:prefetched_object) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Note',
id: 'https://b.example.com/unknown-quoted',
to: 'https://www.w3.org/ns/activitystreams#Public',
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
content: 'previously unknown post',
}.with_indifferent_access
end
before do
stub_request(:get, 'https://b.example.com/unknown-quoted')
.to_return(status: 404)
end
it 'updates the status' do
expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted', prefetched_quoted_object: prefetched_object) }
.to change(quote, :state).to('accepted')
expect(a_request(:get, approval_uri))
.to have_been_made.once
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
end
end
context 'with a valid activity for a post that cannot be fetched but is inlined' do context 'with a valid activity for a post that cannot be fetched but is inlined' do
let(:quoted_status) { nil } let(:quoted_status) { nil }
@ -148,7 +179,7 @@ RSpec.describe ActivityPub::VerifyQuoteService do
context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
it 'updates the status without fetching the activity' do it 'updates the status without fetching the activity' do
expect { subject.call(quote, prefetched_body: Oj.dump(json)) } expect { subject.call(quote, prefetched_approval: Oj.dump(json)) }
.to change(quote, :state).to('accepted') .to change(quote, :state).to('accepted')
expect(a_request(:get, approval_uri)) expect(a_request(:get, approval_uri))

1335
yarn.lock

File diff suppressed because it is too large Load diff