Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote
This commit is contained in:
commit
9de0de7d65
66 changed files with 2420 additions and 1868 deletions
27
CHANGELOG.md
27
CHANGELOG.md
|
@ -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)
|
||||||
|
|
2
Gemfile
2
Gemfile
|
@ -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
|
||||||
|
|
26
Gemfile.lock
26
Gemfile.lock
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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')}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
840
app/javascript/mastodon/features/audio/index.tsx
Normal 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;
|
|
@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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[],
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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))));
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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);
|
|
|
@ -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;
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
@ -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 }) => ({
|
||||||
|
|
|
@ -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}
|
||||||
|
|
112
app/javascript/mastodon/hooks/useAudioVisualizer.ts
Normal file
112
app/javascript/mastodon/hooks/useAudioVisualizer.ts
Normal 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;
|
||||||
|
};
|
|
@ -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}",
|
||||||
|
|
|
@ -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?",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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}\"",
|
||||||
|
|
|
@ -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}에 마지막으로 게시",
|
||||||
|
|
|
@ -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.",
|
||||||
|
|
|
@ -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}",
|
||||||
|
|
|
@ -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}",
|
||||||
|
|
|
@ -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?",
|
||||||
|
|
|
@ -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}",
|
||||||
|
|
|
@ -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": "隱藏",
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>} */
|
||||||
|
|
1
app/javascript/material-icons/400-24px/pip_exit-fill.svg
Normal file
1
app/javascript/material-icons/400-24px/pip_exit-fill.svg
Normal 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 |
1
app/javascript/material-icons/400-24px/pip_exit.svg
Normal file
1
app/javascript/material-icons/400-24px/pip_exit.svg
Normal 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 |
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
fetch_resource_without_id_validation(collection_or_uri, nil, raise_on_error: :temporary)
|
||||||
# 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)
|
|
||||||
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)
|
||||||
|
|
|
@ -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?
|
quote.save
|
||||||
begin
|
|
||||||
quote.save
|
fetch_and_verify_quote!(quote, quote_uri)
|
||||||
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, 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
|
|
||||||
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
16
config/initializers/deprecations.rb
Normal file
16
config/initializers/deprecations.rb
Normal 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
|
|
@ -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
|
||||||
|
|
|
@ -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} ê標頭"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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。"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -35,7 +35,7 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_prerelease
|
def default_prerelease
|
||||||
'alpha.4'
|
'alpha.5'
|
||||||
end
|
end
|
||||||
|
|
||||||
def prerelease
|
def prerelease
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue