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

Conflicts:
- `app/controllers/auth/setup_controller.rb`:
  Upstream removed a method close to a glitch-soc theming-related method.
  Removed the method like upstream did.
pull/59/head
Claire 2023-04-22 10:06:11 +02:00
commit abfdafef1e
120 changed files with 919 additions and 402 deletions

View File

@ -9,6 +9,8 @@ on:
- '.nvmrc'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.snap'
- '.github/workflows/test-js.yml'
@ -19,6 +21,8 @@ on:
- '.nvmrc'
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '**/*.snap'
- '.github/workflows/test-js.yml'

View File

@ -120,7 +120,7 @@ end
group :test do
gem 'capybara', '~> 3.39'
gem 'climate_control'
gem 'faker', '~> 3.1'
gem 'faker', '~> 3.2'
gem 'json-schema', '~> 3.0'
gem 'rack-test', '~> 2.1'
gem 'rails-controller-testing', '~> 1.0'

View File

@ -243,7 +243,7 @@ GEM
tzinfo
excon (0.95.0)
fabrication (2.30.0)
faker (3.1.1)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (1.10.3)
faraday-em_http (~> 1.0)
@ -348,19 +348,19 @@ GEM
ipaddress (0.8.3)
jmespath (1.6.2)
json (2.6.3)
json-canonicalization (0.3.0)
json-canonicalization (0.3.1)
json-jwt (1.15.3)
activesupport (>= 4.2)
aes_key_wrap
bindata
httpclient
json-ld (3.2.3)
json-ld (3.2.4)
htmlentities (~> 4.3)
json-canonicalization (~> 0.3)
link_header (~> 0.0, >= 0.0.8)
multi_json (~> 1.15)
rack (~> 2.2)
rdf (~> 3.2, >= 3.2.9)
rack (>= 2.2, < 4)
rdf (~> 3.2, >= 3.2.10)
json-ld-preloaded (3.2.2)
json-ld (~> 3.2)
rdf (~> 3.2)
@ -479,7 +479,7 @@ GEM
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
orm_adapter (0.5.0)
ox (2.14.14)
ox (2.14.16)
parallel (1.22.1)
parser (3.2.2.0)
ast (~> 2.4.1)
@ -487,7 +487,7 @@ GEM
pastel (0.8.0)
tty-color (~> 0.5)
pg (1.4.6)
pghero (3.3.1)
pghero (3.3.2)
activerecord (>= 6)
pkg-config (1.5.1)
posix-spawn (0.3.15)
@ -557,7 +557,7 @@ GEM
thor (~> 1.0)
rainbow (3.1.1)
rake (13.0.6)
rdf (3.2.9)
rdf (3.2.10)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.5.1)
rdf (~> 3.2)
@ -799,7 +799,7 @@ DEPENDENCIES
dotenv-rails (~> 2.8)
ed25519 (~> 1.3)
fabrication (~> 2.30)
faker (~> 3.1)
faker (~> 3.2)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.4.0)

View File

@ -0,0 +1,72 @@
# frozen_string_literal: true
class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseController
include Authorization
LIMIT = 100
before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
before_action :set_providers, only: :index
after_action :verify_authorized
after_action :insert_pagination_headers, only: :index
PAGINATION_PARAMS = %i(limit).freeze
def index
authorize :preview_card_provider, :index?
render json: @providers, each_serializer: REST::Admin::Trends::Links::PreviewCardProviderSerializer
end
def approve
authorize :preview_card_provider, :review?
provider = PreviewCardProvider.find(params[:id])
provider.update(trendable: true, reviewed_at: Time.now.utc)
render json: provider, serializer: REST::Admin::Trends::Links::PreviewCardProviderSerializer
end
def reject
authorize :preview_card_provider, :review?
provider = PreviewCardProvider.find(params[:id])
provider.update(trendable: false, reviewed_at: Time.now.utc)
render json: provider, serializer: REST::Admin::Trends::Links::PreviewCardProviderSerializer
end
private
def set_providers
@providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty?
end
def pagination_max_id
@providers.last.id
end
def pagination_since_id
@providers.first.id
end
def records_continue?
@providers.size == limit_param(LIMIT)
end
def pagination_params(core_params)
params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
end
end

View File

@ -1,7 +1,36 @@
# frozen_string_literal: true
class Api::V1::Admin::Trends::LinksController < Api::V1::Trends::LinksController
before_action -> { authorize_if_got_token! :'admin:read' }
include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
after_action :verify_authorized, except: :index
def index
if current_user&.can?(:manage_taxonomies)
render json: @links, each_serializer: REST::Admin::Trends::LinkSerializer
else
super
end
end
def approve
authorize :preview_card, :review?
link = PreviewCard.find(params[:id])
link.update(trendable: true)
render json: link, serializer: REST::Admin::Trends::LinkSerializer
end
def reject
authorize :preview_card, :review?
link = PreviewCard.find(params[:id])
link.update(trendable: false)
render json: link, serializer: REST::Admin::Trends::LinkSerializer
end
private

View File

@ -1,7 +1,36 @@
# frozen_string_literal: true
class Api::V1::Admin::Trends::StatusesController < Api::V1::Trends::StatusesController
before_action -> { authorize_if_got_token! :'admin:read' }
include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
after_action :verify_authorized, except: :index
def index
if current_user&.can?(:manage_taxonomies)
render json: @statuses, each_serializer: REST::Admin::Trends::StatusSerializer
else
super
end
end
def approve
authorize [:admin, :status], :review?
status = Status.find(params[:id])
status.update(trendable: true)
render json: status, serializer: REST::Admin::Trends::StatusSerializer
end
def reject
authorize [:admin, :status], :review?
status = Status.find(params[:id])
status.update(trendable: false)
render json: status, serializer: REST::Admin::Trends::StatusSerializer
end
private

View File

@ -1,7 +1,12 @@
# frozen_string_literal: true
class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
before_action -> { authorize_if_got_token! :'admin:read' }
include Authorization
before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
after_action :verify_authorized, except: :index
def index
if current_user&.can?(:manage_taxonomies)
@ -11,6 +16,22 @@ class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
end
end
def approve
authorize :tag, :review?
tag = Tag.find(params[:id])
tag.update(trendable: true, reviewed_at: Time.now.utc)
render json: tag, serializer: REST::Admin::TagSerializer
end
def reject
authorize :tag, :review?
tag = Tag.find(params[:id])
tag.update(trendable: false, reviewed_at: Time.now.utc)
render json: tag, serializer: REST::Admin::TagSerializer
end
private
def enabled?

View File

@ -11,15 +11,7 @@ class Auth::SetupController < ApplicationController
skip_before_action :require_functional!
def show
flash.now[:notice] = begin
if @user.pending?
I18n.t('devise.registrations.signed_up_but_pending')
else
I18n.t('devise.registrations.signed_up_but_unconfirmed')
end
end
end
def show; end
def update
# This allows updating the e-mail without entering a password as is required
@ -27,14 +19,13 @@ class Auth::SetupController < ApplicationController
# that were not confirmed yet
if @user.update(user_params)
redirect_to auth_setup_path, notice: I18n.t('devise.confirmations.send_instructions')
@user.resend_confirmation_instructions unless @user.confirmed?
redirect_to auth_setup_path, notice: I18n.t('auth.setup.new_confirmation_instructions_sent')
else
render :show
end
end
helper_method :missing_email?
private
def require_unconfirmed_or_pending!
@ -53,10 +44,6 @@ class Auth::SetupController < ApplicationController
params.require(:user).permit(:email)
end
def missing_email?
truthy_param?(:missing_email)
end
def set_pack
use_pack 'auth'
end

View File

@ -117,6 +117,10 @@ module ApplicationHelper
content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
end
def check_icon
content_tag(:svg, tag(:path, 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'), xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor')
end
def visibility_icon(status)
if status.public_visibility?
fa_icon('globe', title: I18n.t('statuses.visibilities.public'))

View File

@ -3,6 +3,8 @@
exports[`<AvatarOverlay renders a overlay avatar 1`] = `
<div
className="account__avatar-overlay"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": 46,
@ -15,8 +17,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "36px",
@ -35,8 +35,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
>
<div
className="account__avatar"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
style={
{
"height": "24px",

View File

@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ShortNumber from 'mastodon/components/short_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'mastodon/initial_state';
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
export default class AnimatedNumber extends React.PureComponent {
static propTypes = {
value: PropTypes.number.isRequired,
obfuscate: PropTypes.bool,
};
state = {
direction: 1,
};
componentWillReceiveProps (nextProps) {
if (nextProps.value > this.props.value) {
this.setState({ direction: 1 });
} else if (nextProps.value < this.props.value) {
this.setState({ direction: -1 });
}
}
willEnter = () => {
const { direction } = this.state;
return { y: -1 * direction };
};
willLeave = () => {
const { direction } = this.state;
return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
};
render () {
const { value, obfuscate } = this.props;
const { direction } = this.state;
if (reduceMotion) {
return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
}
const styles = [{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
return (
<TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
))}
</span>
)}
</TransitionMotion>
);
}
}

View File

@ -0,0 +1,58 @@
import React, { useCallback, useState } from 'react';
import ShortNumber from './short_number';
import { TransitionMotion, spring } from 'react-motion';
import { reduceMotion } from '../initial_state';
const obfuscatedCount = (count: number) => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
type Props = {
value: number;
obfuscate?: boolean;
}
export const AnimatedNumber: React.FC<Props> = ({
value,
obfuscate,
})=> {
const [previousValue, setPreviousValue] = useState(value);
const [direction, setDirection] = useState<1|-1>(1);
if (previousValue !== value) {
setPreviousValue(value);
setDirection(value > previousValue ? 1 : -1);
}
const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
const willLeave = useCallback(() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), [direction]);
if (reduceMotion) {
return obfuscate ? <>{obfuscatedCount(value)}</> : <ShortNumber value={value} />;
}
const styles = [{
key: `${value}`,
data: value,
style: { y: spring(0, { damping: 35, stiffness: 400 }) },
}];
return (
<TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
{items => (
<span className='animated-number'>
{items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
))}
</span>
)}
</TransitionMotion>
);
};
export default AnimatedNumber;

View File

@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from '../initial_state';
import Avatar from './avatar';
export default class AvatarOverlay extends React.PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
friend: ImmutablePropTypes.map.isRequired,
animate: PropTypes.bool,
size: PropTypes.number,
baseSize: PropTypes.number,
overlaySize: PropTypes.number,
};
static defaultProps = {
animate: autoPlayGif,
size: 46,
baseSize: 36,
overlaySize: 24,
};
state = {
hovering: false,
};
handleMouseEnter = () => {
if (this.props.animate) return;
this.setState({ hovering: true });
};
handleMouseLeave = () => {
if (this.props.animate) return;
this.setState({ hovering: false });
};
render() {
const { account, friend, animate, size, baseSize, overlaySize } = this.props;
const { hovering } = this.state;
return (
<div className='account__avatar-overlay' style={{ width: size, height: size }}>
<div className='account__avatar-overlay-base'><Avatar animate={hovering || animate} account={account} size={baseSize} /></div>
<div className='account__avatar-overlay-overlay'><Avatar animate={hovering || animate} account={friend} size={overlaySize} /></div>
</div>
);
}
}

View File

@ -0,0 +1,51 @@
import React from 'react';
import type { Account } from '../../types/resources';
import { useHovering } from '../../hooks/useHovering';
import { autoPlayGif } from '../initial_state';
type Props = {
account: Account;
friend: Account;
size?: number;
baseSize?: number;
overlaySize?: number;
};
export const AvatarOverlay: React.FC<Props> = ({
account,
friend,
size = 46,
baseSize = 36,
overlaySize = 24,
}) => {
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(autoPlayGif);
const accountSrc = hovering ? account?.get('avatar') : account?.get('avatar_static');
const friendSrc = hovering ? friend?.get('avatar') : friend?.get('avatar_static');
return (
<div
className='account__avatar-overlay' style={{ width: size, height: size }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className='account__avatar-overlay-base'>
<div
className='account__avatar'
style={{ width: `${baseSize}px`, height: `${baseSize}px` }}
>
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
</div>
</div>
<div className='account__avatar-overlay-overlay'>
<div
className='account__avatar'
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
>
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
</div>
</div>
</div>
);
};
export default AvatarOverlay;

View File

@ -1,76 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
export default class GIFV extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
lang: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
onClick: PropTypes.func,
};
state = {
loading: true,
};
handleLoadedData = () => {
this.setState({ loading: false });
};
componentWillReceiveProps (nextProps) {
if (nextProps.src !== this.props.src) {
this.setState({ loading: true });
}
}
handleClick = e => {
const { onClick } = this.props;
if (onClick) {
e.stopPropagation();
onClick();
}
};
render () {
const { src, width, height, alt, lang } = this.props;
const { loading } = this.state;
return (
<div className='gifv' style={{ position: 'relative' }}>
{loading && (
<canvas
width={width}
height={height}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
onClick={this.handleClick}
/>
)}
<video
src={src}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
muted
loop
autoPlay
playsInline
onClick={this.handleClick}
onLoadedData={this.handleLoadedData}
style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
/>
</div>
);
}
}

View File

@ -0,0 +1,68 @@
import React, { useCallback, useState } from 'react';
type Props = {
src: string;
key: string;
alt?: string;
lang?: string;
width: number;
height: number;
onClick?: () => void;
}
export const GIFV: React.FC<Props> = ({
src,
alt,
lang,
width,
height,
onClick,
})=> {
const [loading, setLoading] = useState(true);
const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = useCallback(() => {
setLoading(false);
}, [setLoading]);
const handleClick: React.MouseEventHandler = useCallback((e) => {
if (onClick) {
e.stopPropagation();
onClick();
}
}, [onClick]);
return (
<div className='gifv' style={{ position: 'relative' }}>
{loading && (
<canvas
width={width}
height={height}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
onClick={handleClick}
/>
)}
<video
src={src}
role='button'
tabIndex={0}
aria-label={alt}
title={alt}
lang={lang}
muted
loop
autoPlay
playsInline
onClick={handleClick}
onLoadedData={handleLoadedData}
style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
/>
</div>
);
};
export default GIFV;

View File

@ -541,7 +541,7 @@ class Status extends ImmutablePureComponent {
expanded={!status.get('hidden')}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
collapsable
collapsible
onCollapsedToggle={this.handleCollapsedToggle}
/>

View File

@ -65,7 +65,7 @@ class StatusContent extends React.PureComponent {
onExpandedToggle: PropTypes.func,
onTranslate: PropTypes.func,
onClick: PropTypes.func,
collapsable: PropTypes.bool,
collapsible: PropTypes.bool,
onCollapsedToggle: PropTypes.func,
languages: ImmutablePropTypes.map,
intl: PropTypes.object,
@ -112,10 +112,10 @@ class StatusContent extends React.PureComponent {
}
if (status.get('collapsed', null) === null && onCollapsedToggle) {
const { collapsable, onClick } = this.props;
const { collapsible, onClick } = this.props;
const collapsed =
collapsable
collapsible
&& onClick
&& node.clientHeight > MAX_HEIGHT
&& status.get('spoiler_text').length === 0;

View File

@ -165,7 +165,7 @@ class Conversation extends ImmutablePureComponent {
onClick={this.handleClick}
expanded={!lastStatus.get('hidden')}
onExpandedToggle={this.handleShowMore}
collapsable
collapsible
/>
{lastStatus.get('media_attachments').size > 0 && (

View File

@ -69,6 +69,7 @@ const messages = defineMessages({
redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}' },
detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
@ -166,13 +167,14 @@ const truncate = (str, num) => {
}
};
const titleFromStatus = status => {
const titleFromStatus = (intl, status) => {
const displayName = status.getIn(['account', 'display_name']);
const username = status.getIn(['account', 'username']);
const prefix = displayName.trim().length === 0 ? username : displayName;
const user = displayName.trim().length === 0 ? username : displayName;
const text = status.get('search_index');
const attachmentCount = status.get('media_attachments').size;
return `${prefix}: "${truncate(text, 30)}"`;
return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount });
};
class Status extends ImmutablePureComponent {
@ -670,7 +672,7 @@ class Status extends ImmutablePureComponent {
</ScrollContainer>
<Helmet>
<title>{titleFromStatus(status)}</title>
<title>{titleFromStatus(intl, status)}</title>
<meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
</Helmet>
</Column>

View File

@ -131,4 +131,4 @@ class FilterModal extends ImmutablePureComponent {
}
export default connect(injectIntl(FilterModal));
export default connect()(injectIntl(FilterModal));

View File

@ -383,7 +383,7 @@ class FocalPointModal extends ImmutablePureComponent {
{focals && (
<div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
{media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
{media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
{media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />}
<div className='focal-point__preview'>
<strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>

View File

@ -186,7 +186,7 @@ class MediaModal extends ImmutablePureComponent {
src={image.get('url')}
width={width}
height={height}
key={image.get('preview_url')}
key={image.get('url')}
alt={image.get('description')}
lang={language}
onClick={this.toggleNavigation}

View File

@ -3732,6 +3732,10 @@
"defaultMessage": "Show less for all",
"id": "status.show_less_all"
},
{
"defaultMessage": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
"id": "status.title.with_attachments"
},
{
"defaultMessage": "Detailed conversation view",
"id": "status.detailed_status"
@ -4354,5 +4358,22 @@
}
],
"path": "app/javascript/mastodon/features/video/index.json"
},
{
"descriptors": [
{
"defaultMessage": "That username is taken. Try another",
"id": "username.taken"
},
{
"defaultMessage": "Password confirmation exceeds the maximum password length",
"id": "password_confirmation.exceeds_maxlength"
},
{
"defaultMessage": "Password confirmation does not match",
"id": "password_confirmation.mismatching"
}
],
"path": "app/javascript/packs/public.json"
}
]

View File

@ -443,6 +443,8 @@
"notifications_permission_banner.enable": "Enable desktop notifications",
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
"notifications_permission_banner.title": "Never miss a thing",
"password_confirmation.exceeds_maxlength": "Password confirmation exceeds the maximum password length",
"password_confirmation.mismatching": "Password confirmation does not match",
"picture_in_picture.restore": "Put it back",
"poll.closed": "Closed",
"poll.refresh": "Refresh",
@ -598,6 +600,7 @@
"status.show_more": "Show more",
"status.show_more_all": "Show more for all",
"status.show_original": "Show original",
"status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
"status.translate": "Translate",
"status.translated_from_with": "Translated from {lang} using {provider}",
"status.uncached_media_warning": "Not available",
@ -650,6 +653,7 @@
"upload_modal.preview_label": "Preview ({ratio})",
"upload_progress.label": "Uploading...",
"upload_progress.processing": "Processing…",
"username.taken": "That username is taken. Try another",
"video.close": "Close video",
"video.download": "Download file",
"video.exit_fullscreen": "Exit full screen",

View File

@ -597,6 +597,7 @@
"status.show_more": "Mostrar más",
"status.show_more_all": "Mostrar más para todo",
"status.show_original": "Mostrar original",
"status.title.with_attachments": "{user} publicó {attachmentCount, plural, one {un archivo adjunto} other {{attachmentCount} archivos adjuntos}}",
"status.translate": "Traducir",
"status.translated_from_with": "Traducido del {lang} usando {provider}",
"status.uncached_media_warning": "No disponible",

View File

@ -4,6 +4,15 @@ import ready from '../mastodon/ready';
import { start } from '../mastodon/common';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
import 'cocoon-js-vanilla';
import axios from 'axios';
import { throttle } from 'lodash';
import { defineMessages } from 'react-intl';
const messages = defineMessages({
usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' },
passwordExceedsLength: { id: 'password_confirmation.exceeds_maxlength', defaultMessage: 'Password confirmation exceeds the maximum password length' },
passwordDoesNotMatch: { id: 'password_confirmation.mismatching', defaultMessage: 'Password confirmation does not match' },
});
start();
@ -13,7 +22,7 @@ function main() {
const { delegate } = require('@rails/ujs');
const emojify = require('../mastodon/features/emoji/emoji').default;
const { getLocale } = require('../mastodon/locales');
const { messages } = getLocale();
const { localeData } = getLocale();
const React = require('react');
const ReactDOM = require('react-dom');
const { createBrowserHistory } = require('history');
@ -58,6 +67,11 @@ function main() {
hour12: false,
});
const formatMessage = ({ id, defaultMessage }, values) => {
const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
return messageFormat.format(values);
};
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
content.innerHTML = emojify(content.innerHTML);
});
@ -77,7 +91,7 @@ function main() {
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
};
const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
[].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
const datetime = new Date(content.getAttribute('datetime'));
@ -103,7 +117,7 @@ function main() {
const timeGiven = content.getAttribute('datetime').includes('T');
content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
content.textContent = timeAgoString({
formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values),
formatMessage,
formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
}, datetime, now, now.getFullYear(), timeGiven);
});
@ -133,17 +147,19 @@ function main() {
scrollToDetailedStatus();
}
delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
const password = document.getElementById('registration_user_password');
const confirmation = document.getElementById('registration_user_password_confirmation');
if (confirmation.value && confirmation.value.length > password.maxLength) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
} else if (password.value && password.value !== confirmation.value) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
const username = document.getElementById('user_account_attributes_username');
if (username.value && username.value.length > 0) {
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
username.setCustomValidity(formatMessage(messages.usernameTaken));
}).catch(() => {
username.setCustomValidity('');
});
} else {
confirmation.setCustomValidity('');
username.setCustomValidity('');
}
});
}, 500, { leading: false, trailing: true }));
delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
const password = document.getElementById('user_password');
@ -151,9 +167,9 @@ function main() {
if (!confirmation) return;
if (confirmation.value && confirmation.value.length > password.maxLength) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
} else if (password.value && password.value !== confirmation.value) {
confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
} else {
confirmation.setCustomValidity('');
}
@ -167,10 +183,10 @@ function main() {
if (statusEl.dataset.spoiler === 'expanded') {
statusEl.dataset.spoiler = 'folded';
this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format();
this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
} else {
statusEl.dataset.spoiler = 'expanded';
this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format();
this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
}
return false;
@ -178,7 +194,7 @@ function main() {
[].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
const statusEl = spoilerLink.parentNode.parentNode;
const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
});
});

View File

@ -1112,3 +1112,89 @@ code {
white-space: nowrap;
}
}
.progress-tracker {
display: flex;
align-items: center;
padding-bottom: 30px;
margin-bottom: 30px;
li {
flex: 0 0 auto;
position: relative;
}
.separator {
height: 2px;
background: $ui-base-lighter-color;
flex: 1 1 auto;
&.completed {
background: $highlight-text-color;
}
}
.circle {
box-sizing: border-box;
position: relative;
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid $ui-base-lighter-color;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
}
}
.label {
position: absolute;
font-size: 14px;
font-weight: 500;
color: $secondary-text-color;
padding-top: 10px;
text-align: center;
width: 100px;
left: 50%;
transform: translateX(-50%);
}
li:first-child .label {
left: auto;
inset-inline-start: 0;
text-align: start;
transform: none;
}
li:last-child .label {
left: auto;
inset-inline-end: 0;
text-align: end;
transform: none;
}
.active .circle {
border-color: $highlight-text-color;
&::before {
content: '';
width: 10px;
height: 10px;
border-radius: 50%;
background: $highlight-text-color;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
.completed .circle {
border-color: $highlight-text-color;
background: $highlight-text-color;
}
}

View File

@ -14,7 +14,7 @@ class NotificationMailer < ApplicationMailer
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail to: @me.user.email, subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
end
end
@ -25,7 +25,7 @@ class NotificationMailer < ApplicationMailer
return unless @me.user.functional?
locale_for_account(@me) do
mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
end
end
@ -38,7 +38,7 @@ class NotificationMailer < ApplicationMailer
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail to: @me.user.email, subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
end
end
@ -51,7 +51,7 @@ class NotificationMailer < ApplicationMailer
locale_for_account(@me) do
thread_by_conversation(@status.conversation)
mail to: @me.user.email, subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
end
end
@ -62,7 +62,7 @@ class NotificationMailer < ApplicationMailer
return unless @me.user.functional?
locale_for_account(@me) do
mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
end
end

View File

@ -55,7 +55,7 @@ class AccountFilter
when 'by_domain'
Account.where(domain: value.to_s.strip)
when 'username'
Account.matches_username(value.to_s.strip)
Account.matches_username(value.to_s.strip.delete_prefix('@'))
when 'display_name'
Account.matches_display_name(value.to_s.strip)
when 'email'

View File

@ -18,6 +18,7 @@
#
class PreviewCardProvider < ApplicationRecord
include Paginable
include DomainNormalizable
include Attachmentable

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::Admin::Trends::LinkSerializer < REST::Trends::LinkSerializer
attributes :id, :requires_review
def requires_review
object.requires_review?
end
end

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
class REST::Admin::Trends::Links::PreviewCardProviderSerializer < ActiveModel::Serializer
attributes :id, :domain, :trendable, :reviewed_at,
:requested_review_at, :requires_review
def requires_review
object.requires_review?
end
end