Merge pull request #3176 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to eb118d85238da8d0583868abab2756bb329742ba
This commit is contained in:
Claire 2025-09-10 20:26:11 +02:00 committed by GitHub
commit cf7d1822f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
289 changed files with 4481 additions and 2126 deletions

View File

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.79.2.
# using RuboCop version 1.80.2.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new

View File

@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions';
import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store';
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// If you want to run the dark theme during development,
// you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss';
import './styles.css';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' },
@ -49,12 +50,17 @@ const preview: Preview = {
locale: 'en',
},
decorators: [
(Story, { parameters }) => {
(Story, { parameters, globals }) => {
const { locale } = globals as { locale: string };
const { state = {} } = parameters;
let reducer = rootReducer;
if (typeof state === 'object' && state) {
reducer = reducerWithInitialState(state as Record<string, unknown>);
}
const reducer = reducerWithInitialState(
{
meta: {
locale,
},
},
state as Record<string, unknown>,
);
const store = configureStore({
reducer,
middleware(getDefaultMiddleware) {

8
.storybook/styles.css Normal file
View File

@ -0,0 +1,8 @@
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@ -771,7 +771,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.4)
rubocop (1.79.2)
rubocop (1.80.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -791,17 +791,17 @@ GEM
rubocop-i18n (3.2.3)
lint_roller (~> 1.1)
rubocop (>= 1.72.1)
rubocop-performance (1.25.0)
rubocop-performance (1.26.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails (2.33.3)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.6.0)
rubocop-rspec (3.7.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec_rails (2.31.0)
@ -817,7 +817,7 @@ GEM
ruby-vips (2.2.5)
ffi (~> 1.12)
logger
rubyzip (3.0.2)
rubyzip (3.1.0)
rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1)
safety_net_attestation (0.4.0)
@ -903,7 +903,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.9.1)
unicode-display_width (3.1.4)
unicode-display_width (3.1.5)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)

View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
class ActivityPub::ContextsController < ActivityPub::BaseController
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode?
before_action :set_conversation
before_action :set_items
DESCENDANTS_LIMIT = 60
def show
expires_in 3.minutes, public: public_fetch_mode?
render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
def items
expires_in 3.minutes, public: public_fetch_mode?
render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
private
def account_required?
false
end
def set_conversation
account_id, status_id = params[:id].split('-')
@conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id)
end
def set_items
@items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
end
def context_presenter
first_page = ActivityPub::CollectionPresenter.new(
id: items_context_url(@conversation, page_params),
type: :unordered,
part_of: items_context_url(@conversation),
next: next_page,
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
)
ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter|
presenter.first = first_page
end
end
def items_collection_presenter
page = ActivityPub::CollectionPresenter.new(
id: items_context_url(@conversation, page_params),
type: :unordered,
part_of: items_context_url(@conversation),
next: next_page,
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
)
return page if page_requested?
ActivityPub::CollectionPresenter.new(
id: items_context_url(@conversation),
type: :unordered,
first: page
)
end
def page_requested?
truthy_param?(:page)
end
def next_page
return nil if @items.size < DESCENDANTS_LIMIT
items_context_url(@conversation, page: true, min_id: @items.last.id)
end
def page_params
params.permit(:page, :min_id)
end
end

View File

@ -49,8 +49,8 @@ module Admin
def export_data
CSV.generate(headers: export_headers, write_headers: true) do |content|
DomainAllow.allowed_domains.each do |instance|
content << [instance.domain]
DomainAllow.allowed_domains.each do |domain|
content << [domain]
end
end
end

View File

@ -6,4 +6,10 @@ class Settings::Preferences::PostingDefaultsController < Settings::Preferences::
def after_update_redirect_path
settings_preferences_posting_defaults_path
end
def user_params
super.tap do |params|
params[:settings_attributes][:default_quote_policy] = 'nobody' if params[:settings_attributes][:default_privacy] == 'private'
end
end
end

View File

@ -145,6 +145,10 @@ function loaded() {
);
});
updateDefaultQuotePrivacyFromPrivacy(
document.querySelector('#user_settings_attributes_default_privacy'),
);
const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) {
@ -364,6 +368,34 @@ Rails.delegate(
},
);
const updateDefaultQuotePrivacyFromPrivacy = (
privacySelect: EventTarget | null,
) => {
if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form)
return;
const select = privacySelect.form.querySelector<HTMLSelectElement>(
'select#user_settings_attributes_default_quote_policy',
);
if (!select) return;
if (privacySelect.value === 'private') {
select.value = 'nobody';
setInputDisabled(select, true);
} else {
setInputDisabled(select, false);
}
};
Rails.delegate(
document,
'#user_settings_attributes_default_privacy',
'change',
({ target }) => {
updateDefaultQuotePrivacyFromPrivacy(target);
},
);
// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {

View File

@ -16,6 +16,7 @@ import type { Status } from '../models/status';
import { showAlert } from './alerts';
import { focusCompose } from './compose';
import { openModal } from './modal';
const messages = defineMessages({
quoteErrorUpload: {
@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk(
export const quoteComposeByStatus = createAppThunk(
(status: Status, { dispatch, getState }) => {
const composeState = getState().compose;
const state = getState();
const composeState = state.compose;
const mediaAttachments = composeState.get('media_attachments');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const wasQuietPostHintModalDismissed: boolean =
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
state.settings.getIn(
['dismissed_banners', 'quote/quiet_post_hint'],
false,
);
if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll }));
@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk(
status.getIn(['quote_approval', 'current_user']) !== 'manual'
) {
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
} else if (
status.get('visibility') === 'unlisted' &&
!wasQuietPostHintModalDismissed
) {
dispatch(
openModal({
modalType: 'CONFIRM_QUIET_QUOTE',
modalProps: { status },
}),
);
} else {
dispatch(quoteCompose(status));
}

View File

@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
return normalResult;
}
function stripQuoteFallback(text) {
const wrapper = document.createElement('div');
wrapper.innerHTML = text;
wrapper.querySelector('.quote-inline')?.remove();
return wrapper.innerHTML;
}
export function normalizeStatus(status, normalOldStatus, settings) {
const normalStatus = { ...status };
@ -78,6 +87,11 @@ export function normalizeStatus(status, normalOldStatus, settings) {
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
if (normalStatus.quote) {
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
}
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null;
}
@ -117,6 +131,11 @@ export function normalizeStatusTranslation(translation, status) {
spoiler_text: translation.spoiler_text,
};
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
if (status.get('quote')) {
normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
}
return normalTranslation;
}

View File

@ -13,9 +13,9 @@ import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const AltTextBadge: React.FC<{
description: string;
}> = ({ description }) => {
export const AltTextBadge: React.FC<{ description: string }> = ({
description,
}) => {
const accessibilityId = useId();
const anchorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
{({ props }) => (
<div {...props} className='hover-card-controller'>
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
className='media-gallery__alt__popover dropdown-animation'
className='info-tooltip dropdown-animation'
role='region'
id={accessibilityId}
onMouseDown={handleMouseDown}

View File

@ -9,6 +9,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean;
secondary?: boolean;
plain?: boolean;
compact?: boolean;
dangerous?: boolean;
loading?: boolean;
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
disabled,
block,
secondary,
plain,
compact,
dangerous,
loading,
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
<button
className={classNames('button', className, {
'button-secondary': secondary,
'button--plain': plain,
'button--compact': compact,
'button--block': block,
'button--dangerous': dangerous,

View File

@ -1,8 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
-- the settings store is not yet typed */
import type { PropsWithChildren } from 'react';
import { useCallback, useState, useEffect } from 'react';
@ -23,31 +18,48 @@ interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const dismissed = useAppSelector((state) =>
export function useDismissableBannerState({ id }: Props) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const dismissed: boolean = useAppSelector((state) =>
/* eslint-disable-next-line */
state.settings.getIn(['dismissed_banners', id], false),
);
const [isVisible, setIsVisible] = useState(
!bannerSettings.get(id) && !dismissed,
);
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
const dismiss = useCallback(() => {
setIsVisible(false);
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
if (!visible && !dismissed) {
// Store legacy localStorage setting on server
if (!isVisible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, visible, dismissed]);
}, [id, dispatch, isVisible, dismissed]);
if (!visible) {
return {
isVisible,
dismiss,
};
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const intl = useIntl();
const { isVisible, dismiss } = useDismissableBannerState({
id,
});
if (!isVisible) {
return null;
}
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
icon='times'
iconComponent={CloseIcon}
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
onClick={dismiss}
/>
</div>

View File

@ -0,0 +1,122 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { useMemo } from 'react';
import classNames from 'classnames';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
import type { Account } from '@/flavours/glitch/models/account';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { Skeleton } from '../skeleton';
interface Props {
account?: Account;
localDomain?: string;
simple?: boolean;
noDomain?: boolean;
}
export const DisplayName: FC<Props & ComponentPropsWithoutRef<'span'>> = ({
account,
localDomain,
simple = false,
noDomain = false,
className,
...props
}) => {
const username = useMemo(() => {
if (!account || noDomain) {
return null;
}
let acct = account.get('acct');
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}
return `@${acct}`;
}, [account, localDomain, noDomain]);
if (!account) {
if (simple) {
return null;
}
return (
<span {...props} className={classNames('display-name', className)}>
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
{!noDomain && (
<span className='display-name__account'>
&nbsp;
<Skeleton width='7ch' />
</span>
)}
</span>
);
}
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
if (simple) {
return (
<bdi>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
</bdi>
);
}
return (
<span {...props} className={classNames('display-name', className)}>
<bdi>
<EmojiHTML
className='display-name__html'
htmlString={accountName}
shallow
as='strong'
/>
</bdi>
{username && (
<span className='display-name__account'>&nbsp;{username}</span>
)}
</span>
);
};
export const LinkedDisplayName: FC<
Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial<LinkProps>
> = ({
account,
asProps = {},
className,
localDomain,
simple,
noDomain,
...linkProps
}) => {
const displayProps = {
account,
className,
localDomain,
simple,
noDomain,
...asProps,
};
if (!account) {
return <DisplayName {...displayProps} />;
}
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
{...linkProps}
>
<DisplayName {...displayProps} />
</Link>
);
};

View File

@ -1,22 +1,28 @@
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { ComponentPropsWithoutRef, FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
import type { SelectItem } from '../dropdown_selector';
import { DropdownSelector } from '../dropdown_selector';
import { Icon } from '../icon';
import { matchWidth } from './utils';
interface DropdownProps {
title: string;
disabled?: boolean;
items: SelectItem[];
onChange: (value: string) => void;
current: string;
labelId: string;
descriptionId?: string;
emptyText?: MessageDescriptor;
classPrefix: string;
}
@ -24,39 +30,59 @@ interface DropdownProps {
export const Dropdown: FC<
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
> = ({
title,
disabled,
items,
current,
onChange,
labelId,
descriptionId,
classPrefix,
className,
id,
...buttonProps
}) => {
const intl = useIntl();
const buttonRef = useRef<HTMLButtonElement>(null);
const accessibilityId = useId();
const uniqueId = useId();
const buttonId = id ?? `${uniqueId}-button`;
const listboxId = `${uniqueId}-listbox`;
const [open, setOpen] = useState(false);
const handleToggle = useCallback(() => {
if (!disabled) {
setOpen((prevOpen) => !prevOpen);
setOpen((prevOpen) => {
buttonRef.current?.focus();
return !prevOpen;
});
}
}, [disabled]);
const handleClose = useCallback(() => {
setOpen(false);
buttonRef.current?.focus();
}, []);
const currentText = useMemo(
() => items.find((i) => i.value === current)?.text,
[current, items],
() =>
items.find((i) => i.value === current)?.text ??
intl.formatMessage({
id: 'dropdown.empty',
defaultMessage: 'Select an option',
}),
[current, intl, items],
);
return (
<>
<button
type='button'
{...buttonProps}
title={title}
id={buttonId}
aria-labelledby={`${labelId} ${buttonId}`}
aria-describedby={descriptionId}
aria-expanded={open}
aria-controls={accessibilityId}
aria-controls={listboxId}
onClick={handleToggle}
disabled={disabled}
className={classNames(
@ -69,23 +95,24 @@ export const Dropdown: FC<
)}
ref={buttonRef}
>
{currentText ?? (
<FormattedMessage
id='dropdown.empty'
defaultMessage='Select an option'
/>
)}
{currentText}
<Icon
id='unfold-icon'
icon={UnfoldMoreIcon}
className={`${classPrefix}__icon`}
/>
</button>
<Overlay
show={open}
offset={[0, 4]}
offset={[0, 0]}
placement='bottom-start'
onHide={handleClose}
flip
target={buttonRef.current}
popperConfig={{
strategy: 'fixed',
modifiers: [matchWidth],
}}
>
{({ props, placement }) => (
@ -96,7 +123,7 @@ export const Dropdown: FC<
`${classPrefix}__dropdown`,
placement,
)}
id={accessibilityId}
id={listboxId}
>
<DropdownSelector
items={items}

View File

@ -0,0 +1,17 @@
import type { Modifier, UsePopperState } from 'react-overlays/esm/usePopper';
export const matchWidth: Modifier<'sameWidth', UsePopperState> = {
name: 'sameWidth',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
fn: ({ state }) => {
if (state.styles.popper) {
state.styles.popper.width = `${state.rects.reference.width}px`;
}
},
effect: ({ state }) => {
const reference = state.elements.reference as HTMLElement;
state.elements.popper.style.width = `${reference.offsetWidth}px`;
},
};

View File

@ -36,6 +36,7 @@ import {
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { Icon } from './icon';
import type { IconProp } from './icon';
import { IconButton } from './icon_button';
@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
onItemClick?: ItemClickFn<Item>;
}
export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
item,
}) => {
if (item === null) {
return null;
}
const { text, description, icon } = item;
return (
<>
{icon && <Icon icon={icon} id={`${text}-icon`} />}
<span className='dropdown-menu__item-content'>
{text}
{Boolean(description) && (
<span className='dropdown-menu__item-subtitle'>{description}</span>
)}
</span>
</>
);
};
export const DropdownMenu = <Item = MenuItem,>({
items,
loading,
@ -200,7 +222,7 @@ export const DropdownMenu = <Item = MenuItem,>({
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
@ -211,8 +233,9 @@ export const DropdownMenu = <Item = MenuItem,>({
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
disabled={disabled}
>
{text}
<DropdownMenuItemContent item={option} />
</button>
);
} else if (isExternalLinkItem(option)) {
@ -227,7 +250,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</a>
);
} else {
@ -239,7 +262,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</Link>
);
}
@ -247,6 +270,7 @@ export const DropdownMenu = <Item = MenuItem,>({
return (
<li
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--highlighted': highlighted,
'dropdown-menu__item--dangerous': dangerous,
})}
key={`${text}-${i}`}

View File

@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
onClose,
onChange,
}) => {
const nodeRef = useRef<HTMLUListElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const focusedItemRef = useRef<HTMLLIElement>(null);
const [currentValue, setCurrentValue] = useState(value);
const handleDocumentClick = useCallback(
(e: MouseEvent | TouchEvent) => {
if (
nodeRef.current &&
e.target instanceof Node &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
},
[nodeRef, onClose],
);
const handleClick = useCallback(
(
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
break;
case 'ArrowDown':
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
listRef.current?.children[index + 1] ??
listRef.current?.firstElementChild;
break;
case 'ArrowUp':
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
listRef.current?.children[index - 1] ??
listRef.current?.lastElementChild;
break;
case 'Tab':
if (e.shiftKey) {
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
listRef.current?.children[index - 1] ??
listRef.current?.lastElementChild;
} else {
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
listRef.current?.children[index + 1] ??
listRef.current?.firstElementChild;
}
break;
case 'Home':
element = nodeRef.current?.firstElementChild;
element = listRef.current?.firstElementChild;
break;
case 'End':
element = nodeRef.current?.lastElementChild;
element = listRef.current?.lastElementChild;
break;
}
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
e.stopPropagation();
}
},
[nodeRef, items, onClose, handleClick, setCurrentValue],
[items, onClose, handleClick, setCurrentValue],
);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
if (
listRef.current &&
e.target instanceof Node &&
!listRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
};
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
focusedItemRef.current?.focus({ preventScroll: true });
return () => {
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
listenerOptions,
);
};
}, [handleDocumentClick]);
}, [onClose]);
return (
<ul style={style} role='listbox' ref={nodeRef}>
<ul style={style} role='listbox' ref={listRef}>
{items.map((item) => (
<li
role='option'

View File

@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
import { statusFactoryState } from '@/testing/factories';
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
interface StoryProps {
visibility: StatusVisibility;
@ -13,7 +13,7 @@ interface StoryProps {
}
const meta = {
title: 'Components/Status/ReblogButton',
title: 'Components/Status/BoostButton',
args: {
visibility: 'public',
quoteAllowed: true,
@ -38,7 +38,7 @@ const meta = {
},
},
render: (args) => (
<StatusReblogButton
<StatusBoostButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>

View File

@ -0,0 +1,253 @@
import { useCallback, useMemo } from 'react';
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
import { toggleReblog } from '@/flavours/glitch/actions/interactions';
import { openModal } from '@/flavours/glitch/actions/modal';
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
import type { Status } from '@/flavours/glitch/models/status';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
import type { SomeRequired } from '@/flavours/glitch/utils/types';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
import { IconButton } from '../icon_button';
import {
boostItemState,
messages,
quoteItemState,
selectStatusState,
} from './boost_button_utils';
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
item,
index,
handlers,
focusRefCallback,
) => (
<ReblogMenuItem
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
);
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
export const StatusBoostButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const {
isLoggedIn,
isReblogged,
isReblogAllowed,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
} = statusState;
const isMenuDisabled =
!isQuoteAutomaticallyAccepted &&
!isQuoteManuallyAccepted &&
!isReblogAllowed;
const statusId = status.get('id') as string;
const wasBoosted = !!status.get('reblogged');
const items = useMemo(() => {
const boostItem = boostItemState(statusState);
const quoteItem = quoteItemState(statusState);
return [
{
text: intl.formatMessage(boostItem.title),
description: boostItem.meta
? intl.formatMessage(boostItem.meta)
: undefined,
icon: boostItem.iconComponent,
highlighted: wasBoosted,
disabled: boostItem.disabled,
action: (event) => {
if (isLoggedIn) {
dispatch(toggleReblog(statusId, event.shiftKey));
}
},
},
{
text: intl.formatMessage(quoteItem.title),
description: quoteItem.meta
? intl.formatMessage(quoteItem.meta)
: undefined,
icon: quoteItem.iconComponent,
disabled: quoteItem.disabled,
action: () => {
if (isLoggedIn) {
dispatch(quoteComposeById(statusId));
}
},
},
] satisfies [ActionMenuItemWithIcon, ActionMenuItemWithIcon];
}, [dispatch, intl, isLoggedIn, statusId, statusState, wasBoosted]);
const boostIcon = items[0].icon;
const handleDropdownOpen = useCallback(
(event: MouseEvent | KeyboardEvent) => {
if (!isLoggedIn) {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
} else if (event.shiftKey) {
dispatch(toggleReblog(status.get('id'), true));
return false;
}
return true;
},
[dispatch, isLoggedIn, status],
);
return (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={isMenuDisabled}
>
<IconButton
title={intl.formatMessage(
isMenuDisabled ? messages.all_disabled : messages.reblog_or_quote,
)}
icon='retweet'
iconComponent={boostIcon}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
index,
item,
handlers,
focusRefCallback,
}) => {
const { text, highlighted, disabled } = item;
return (
<li
className={classNames('dropdown-menu__item reblog-menu-item', {
'dropdown-menu__item--highlighted': highlighted,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<DropdownMenuItemContent item={item} />
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const BoostButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusBoostButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() => boostItemState(statusState),
[statusState],
);
const dispatch = useAppDispatch();
const handleClick: MouseEventHandler = useCallback(
(event) => {
if (statusState.isLoggedIn) {
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, statusState.isLoggedIn],
);
return (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};

View File

@ -0,0 +1,161 @@
import { defineMessages } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import type { Status, StatusVisibility } from '@/flavours/glitch/models/status';
import { createAppSelector } from '@/flavours/glitch/store';
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.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 type { IconProp } from '../icon';
export const messages = defineMessages({
all_disabled: {
id: 'status.all_disabled',
defaultMessage: 'Boosts and quotes are disabled',
},
quote: { id: 'status.quote', defaultMessage: 'Quote' },
quote_cannot: {
id: 'status.cannot_quote',
defaultMessage: 'Quotes are disabled on this post',
},
quote_followers_only: {
id: 'status.quote_followers_only',
defaultMessage: 'Only followers can quote this post',
},
quote_manual_review: {
id: 'status.quote_manual_review',
defaultMessage: 'Author will manually review',
},
quote_private: {
id: 'status.quote_private',
defaultMessage: 'Private posts cannot be quoted',
},
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_or_quote: {
id: 'status.reblog_or_quote',
defaultMessage: 'Boost or quote',
},
reblog_cancel: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Share again with your followers',
},
reblog_cannot: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
request_quote: {
id: 'status.request_quote',
defaultMessage: 'Request to quote',
},
});
export const selectStatusState = createAppSelector(
[
(state) => state.meta.get('me') as string | undefined,
(_, status: Status) => status,
],
(userId, status) => {
const isPublic = ['public', 'unlisted'].includes(
status.get('visibility') as StatusVisibility,
);
const isMineAndPrivate =
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private';
return {
isLoggedIn: !!userId,
isPublic,
isMine: userId === status.getIn(['account', 'id']),
isPrivateReblog:
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private',
isReblogged: !!status.get('reblogged'),
isReblogAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
(isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
(isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
};
},
);
export type StatusState = ReturnType<typeof selectStatusState>;
export interface MenuItemState {
title: MessageDescriptor;
meta?: MessageDescriptor;
iconComponent: IconProp;
disabled?: boolean;
}
export function boostItemState({
isPublic,
isPrivateReblog,
isReblogged,
}: StatusState): MenuItemState {
if (isReblogged) {
return {
title: messages.reblog_cancel,
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
};
}
const iconText: MenuItemState = {
title: messages.reblog,
iconComponent: RepeatIcon,
};
if (isPrivateReblog) {
iconText.meta = messages.reblog_private;
iconText.iconComponent = RepeatPrivateIcon;
} else if (!isPublic) {
iconText.meta = messages.reblog_cannot;
iconText.iconComponent = RepeatDisabledIcon;
iconText.disabled = true;
}
return iconText;
}
export function quoteItemState({
isMine,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
isQuoteFollowersOnly,
isPublic,
}: StatusState): MenuItemState {
const iconText: MenuItemState = {
title: messages.quote,
iconComponent: FormatQuote,
};
if (!isPublic && !isMine) {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = messages.quote_private;
} else if (isQuoteAutomaticallyAccepted) {
iconText.title = messages.quote;
} else if (isQuoteManuallyAccepted) {
iconText.title = messages.request_quote;
iconText.meta = messages.quote_manual_review;
} else {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = isQuoteFollowersOnly
? messages.quote_followers_only
: messages.quote_cannot;
}
return iconText;
}

View File

@ -1,425 +0,0 @@
import { useCallback, useMemo } from 'react';
import type {
FC,
KeyboardEvent,
MouseEvent,
MouseEventHandler,
SVGProps,
} from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
import { toggleReblog } from '@/flavours/glitch/actions/interactions';
import { openModal } from '@/flavours/glitch/actions/modal';
import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu';
import type { Status, StatusVisibility } from '@/flavours/glitch/models/status';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/flavours/glitch/store';
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.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 type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
import { Dropdown } from '../dropdown_menu';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
const messages = defineMessages({
all_disabled: {
id: 'status.all_disabled',
defaultMessage: 'Boosts and quotes are disabled',
},
quote: { id: 'status.quote', defaultMessage: 'Quote' },
quote_cannot: {
id: 'status.cannot_quote',
defaultMessage: 'Quotes are disabled on this post',
},
quote_followers_only: {
id: 'status.quote_followers_only',
defaultMessage: 'Only followers can quote this post',
},
quote_manual_review: {
id: 'status.quote_manual_review',
defaultMessage: 'Author will manually review',
},
quote_private: {
id: 'status.quote_private',
defaultMessage: 'Private posts cannot be quoted',
},
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_or_quote: {
id: 'status.reblog_or_quote',
defaultMessage: 'Boost or quote',
},
reblog_cancel: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Boost with original visibility',
},
reblog_cannot: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
request_quote: {
id: 'status.request_quote',
defaultMessage: 'Request to quote',
},
});
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
export const StatusReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const {
isLoggedIn,
isReblogged,
isReblogAllowed,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
} = statusState;
const { iconComponent } = useMemo(
() => reblogIconText(statusState),
[statusState],
);
const disabled =
!isQuoteAutomaticallyAccepted &&
!isQuoteManuallyAccepted &&
!isReblogAllowed;
const dispatch = useAppDispatch();
const statusId = status.get('id') as string;
const items: ActionMenuItem[] = useMemo(
() => [
{
text: 'reblog',
action: (event) => {
if (isLoggedIn) {
dispatch(toggleReblog(statusId, event.shiftKey));
}
},
},
{
text: 'quote',
action: () => {
if (isLoggedIn) {
dispatch(quoteComposeById(statusId));
}
},
},
],
[dispatch, isLoggedIn, statusId],
);
const handleDropdownOpen = useCallback(
(event: MouseEvent | KeyboardEvent) => {
if (!isLoggedIn) {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
} else if (event.shiftKey) {
dispatch(toggleReblog(status.get('id'), true));
return false;
}
return true;
},
[dispatch, isLoggedIn, status],
);
const renderMenuItem: RenderItemFn<ActionMenuItem> = useCallback(
(item, index, handlers, focusRefCallback) => (
<ReblogMenuItem
status={status}
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
),
[status],
);
return (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={disabled}
>
<IconButton
title={intl.formatMessage(
!disabled ? messages.reblog_or_quote : messages.all_disabled,
)}
icon='retweet'
iconComponent={iconComponent}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
status: Status;
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
status,
index,
item: { text },
handlers,
focusRefCallback,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() =>
text === 'quote'
? quoteIconText(statusState)
: reblogIconText(statusState),
[statusState, text],
);
const active = useMemo(
() => text === 'reblog' && !!status.get('reblogged'),
[status, text],
);
return (
<li
className={classNames('dropdown-menu__item reblog-button__item', {
disabled,
active,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
title={intl.formatMessage(title)}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<Icon
id={text === 'quote' ? 'quote' : 'retweet'}
icon={iconComponent}
/>
<div>
{intl.formatMessage(title)}
{meta && (
<span className='reblog-button__meta'>
{intl.formatMessage(meta)}
</span>
)}
</div>
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const ReblogButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusReblogButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() => reblogIconText(statusState),
[statusState],
);
const dispatch = useAppDispatch();
const handleClick: MouseEventHandler = useCallback(
(event) => {
if (statusState.isLoggedIn) {
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, statusState.isLoggedIn],
);
return (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};
// Helpers for copy and state for status.
const selectStatusState = createAppSelector(
[
(state) => state.meta.get('me') as string | undefined,
(_, status: Status) => status,
],
(userId, status) => {
const isPublic = ['public', 'unlisted'].includes(
status.get('visibility') as StatusVisibility,
);
const isMineAndPrivate =
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private';
return {
isLoggedIn: !!userId,
isPublic,
isMine: userId === status.getIn(['account', 'id']),
isPrivateReblog:
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private',
isReblogged: !!status.get('reblogged'),
isReblogAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
(isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
(isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
};
},
);
type StatusState = ReturnType<typeof selectStatusState>;
interface IconText {
title: MessageDescriptor;
meta?: MessageDescriptor;
iconComponent: FC<SVGProps<SVGSVGElement>>;
disabled?: boolean;
}
function reblogIconText({
isPublic,
isPrivateReblog,
isReblogged,
}: StatusState): IconText {
if (isReblogged) {
return {
title: messages.reblog_cancel,
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
};
}
const iconText: IconText = {
title: messages.reblog,
iconComponent: RepeatIcon,
};
if (isPrivateReblog) {
iconText.meta = messages.reblog_private;
iconText.iconComponent = RepeatPrivateIcon;
} else if (!isPublic) {
iconText.meta = messages.reblog_cannot;
iconText.iconComponent = RepeatDisabledIcon;
iconText.disabled = true;
}
return iconText;
}
function quoteIconText({
isMine,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
isQuoteFollowersOnly,
isPublic,
}: StatusState): IconText {
const iconText: IconText = {
title: messages.quote,
iconComponent: FormatQuote,
};
if (!isPublic && !isMine) {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = messages.quote_private;
} else if (isQuoteAutomaticallyAccepted) {
iconText.title = messages.quote;
} else if (isQuoteManuallyAccepted) {
iconText.title = messages.request_quote;
iconText.meta = messages.quote_manual_review;
} else {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = isQuoteFollowersOnly
? messages.quote_followers_only
: messages.quote_cannot;
}
return iconText;
}

View File

@ -22,12 +22,13 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { me } from '../initial_state';
import { me } from '../../initial_state';
import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp';
import { isFeatureEnabled } from '../utils/environment';
import { ReblogButton } from './status/reblog_button';
import { IconButton } from '../icon_button';
import { RelativeTimestamp } from '../relative_timestamp';
import { isFeatureEnabled } from '../../utils/environment';
import { BoostButton } from '../status/boost_button';
import { RemoveQuoteHint } from './remove_quote_hint';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
identity: identityContextPropShape,
status: ImmutablePropTypes.map.isRequired,
quotedAccountId: PropTypes.string,
contextType: PropTypes.string,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onDelete: PropTypes.func,
@ -213,7 +215,7 @@ class StatusActionBar extends ImmutablePureComponent {
};
render () {
const { status, quotedAccountId, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
const { status, quotedAccountId, contextType, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props;
const { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -221,6 +223,7 @@ class StatusActionBar extends ImmutablePureComponent {
const mutingConversation = status.get('muted');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const isQuotingMe = quotedAccountId === me;
let menu = [];
let reblogIcon = 'retweet';
@ -271,7 +274,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push(null);
}
if (quotedAccountId === me) {
if (isQuotingMe) {
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: status.getIn(['account', 'username']) }), action: this.handleRevokeQuoteClick, dangerous: true });
}
@ -320,6 +323,8 @@ class StatusActionBar extends ImmutablePureComponent {
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
return (
<div className='status__action-bar'>
<div className='status__action-bar__button-wrapper'>
@ -334,7 +339,7 @@ class StatusActionBar extends ImmutablePureComponent {
/>
</div>
<div className='status__action-bar__button-wrapper'>
<ReblogButton status={status} counters={withCounters} />
<BoostButton status={status} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
@ -345,18 +350,24 @@ class StatusActionBar extends ImmutablePureComponent {
{filterButton}
<div className='status__action-bar__button-wrapper'>
<Dropdown
scrollKey={scrollKey}
status={status}
items={menu}
icon='ellipsis-h'
size={18}
iconComponent={MoreHorizIcon}
direction='right'
ariaLabel={intl.formatMessage(messages.more)}
/>
</div>
<RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
{(dismissQuoteHint) => (
<Dropdown
scrollKey={scrollKey}
status={status}
items={menu}
icon='ellipsis-h'
size={18}
iconComponent={MoreHorizIcon}
direction='right'
ariaLabel={intl.formatMessage(messages.more)}
onOpen={() => {
dismissQuoteHint();
return true;
}}
/>
)}
</RemoveQuoteHint>
<div className='status__action-bar-spacer' />
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>

View File

@ -0,0 +1,90 @@
import { useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Button } from '../button';
import { useDismissableBannerState } from '../dismissable_banner';
import { Icon } from '../icon';
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
export const RemoveQuoteHint: React.FC<{
canShowHint: boolean;
className?: string;
children: (dismiss: () => void) => React.ReactNode;
}> = ({ canShowHint, className, children }) => {
const anchorRef = useRef<HTMLDivElement>(null);
const intl = useIntl();
const { isVisible, dismiss } = useDismissableBannerState({
id: DISMISSABLE_BANNER_ID,
});
return (
<div className={className} ref={anchorRef}>
{children(dismiss)}
{isVisible && canShowHint && (
<Overlay
show
flip
offset={[12, 10]}
placement='bottom-end'
target={anchorRef.current}
container={anchorRef.current}
>
{({ props, placement }) => (
<div
{...props}
className={classNames(
'info-tooltip info-tooltip--solid dropdown-animation',
placement,
)}
>
<h4>
<FormattedMessage
id='remove_quote_hint.title'
defaultMessage='Want to remove your quoted post?'
/>
</h4>
<FormattedMessage
id='remove_quote_hint.message'
defaultMessage='You can do so from the {icon} options menu.'
values={{
icon: (
<Icon
id='ellipsis-h'
icon={MoreHorizIcon}
aria-label={intl.formatMessage({
id: 'status.more',
defaultMessage: 'More',
})}
style={{ verticalAlign: 'middle' }}
/>
),
}}
>
{(text) => <p>{text}</p>}
</FormattedMessage>
<FormattedMessage
id='remove_quote_hint.button_label'
defaultMessage='Got it'
>
{(text) => (
<Button plain compact onClick={dismiss}>
{text}
</Button>
)}
</FormattedMessage>
</div>
)}
</Overlay>
)}
</div>
);
};

View File

@ -235,16 +235,6 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed);
}
// Remove quote fallback link from the DOM so it doesn't
// mess with paragraph margins
if (!!status.get('quote')) {
const inlineQuote = node.querySelector('.quote-inline');
if (inlineQuote) {
inlineQuote.remove();
}
}
}
handleMouseEnter = ({ currentTarget }) => {

View File

@ -145,6 +145,10 @@ function loaded() {
);
});
updateDefaultQuotePrivacyFromPrivacy(
document.querySelector('#user_settings_attributes_default_privacy'),
);
const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) {
@ -364,6 +368,34 @@ Rails.delegate(
},
);
const updateDefaultQuotePrivacyFromPrivacy = (
privacySelect: EventTarget | null,
) => {
if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form)
return;
const select = privacySelect.form.querySelector<HTMLSelectElement>(
'select#user_settings_attributes_default_quote_policy',
);
if (!select) return;
if (privacySelect.value === 'private') {
select.value = 'nobody';
setInputDisabled(select, true);
} else {
setInputDisabled(select, false);
}
};
Rails.delegate(
document,
'#user_settings_attributes_default_privacy',
'change',
({ target }) => {
updateDefaultQuotePrivacyFromPrivacy(target);
},
);
// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {

View File

@ -18,7 +18,7 @@ export const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Hidden from Mastodon search results, trending, and public timelines' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },

View File

@ -79,10 +79,12 @@ const visibilityOptions = {
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
const intl = useIntl();
const { visibility, quotePolicy } = useAppSelector((state) => ({
visibility: state.compose.get('privacy') as StatusVisibility,
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
}));
const quotePolicy = useAppSelector(
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
);
const visibility = useAppSelector(
(state) => state.compose.get('privacy') as StatusVisibility,
);
const { icon, iconComponent } = useMemo(() => {
const option = visibilityOptions[visibility];

View File

@ -12,16 +12,21 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
};
export const ModernEmojiHTML = <Element extends ElementType>({
export const ModernEmojiHTML = ({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
...props
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;

View File

@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import { emojifyElement, emojifyText } from './render';
import type {
CustomEmojiMapArg,
EmojiAppState,
@ -15,7 +16,17 @@ import type {
} from './types';
import { stringHasAnyEmoji } from './utils';
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
interface UseEmojifyOptions {
text: string;
extraEmojis?: CustomEmojiMapArg;
deep?: boolean;
}
export function useEmojify({
text,
extraEmojis,
deep = true,
}: UseEmojifyOptions) {
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
const appState = useEmojiAppState();
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
const emojify = useCallback(
async (input: string) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const { emojifyElement } = await import('./render');
const result = await emojifyElement(wrapper, appState, extra);
let result: string | null = null;
if (deep) {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
if (await emojifyElement(wrapper, appState, extra)) {
result = wrapper.innerHTML;
}
} else {
result = await emojifyText(text, appState, extra);
}
if (result) {
setEmojifiedText(result.innerHTML);
setEmojifiedText(result);
} else {
setEmojifiedText(input);
}
},
[appState, extra],
[appState, deep, extra, text],
);
useLayoutEffect(() => {
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {

View File

@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
</div>
<div className='app-form__toggle__toggle'>
<div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options}
/>
</div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options}
/>
</div>
</label>
);

View File

@ -36,7 +36,7 @@ const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Boost with original visibility',
defaultMessage: 'Share again with your followers',
},
cancel_reblog_private: {
id: 'status.cancel_reblog_private',

View File

@ -21,7 +21,7 @@ import { IconButton } from '../../../components/icon_button';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { me } from '../../../initial_state';
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
import { ReblogButton } from '@/flavours/glitch/components/status/reblog_button';
import { BoostButton } from '@/flavours/glitch/components/status/boost_button';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -254,7 +254,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={replyIcon} iconComponent={replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'>
<ReblogButton status={status} />
<BoostButton status={status} />
</div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>

View File

@ -1,6 +1,7 @@
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { DropdownMenuItemContent } from 'flavours/glitch/components/dropdown_menu';
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
import {
isActionItem,
@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
if (isActionItem(option)) {
element = (
<button onClick={onClick} data-index={i}>
{text}
<button onClick={onClick} data-index={i} disabled={disabled}>
<DropdownMenuItemContent item={option} />
</button>
);
} else if (isExternalLinkItem(option)) {
@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{
onClick={onClick}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</a>
);
} else {
element = (
<Link to={option.to} onClick={onClick} data-index={i}>
{text}
<DropdownMenuItemContent item={option} />
</Link>
);
}
return (
<li
className={classNames({
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--dangerous': dangerous,
'dropdown-menu__item--highlighted': highlighted,
})}
key={`${text}-${i}`}
>

View File

@ -51,10 +51,6 @@ export const ConfirmationModal: React.FC<
onSecondary?.();
}, [onClose, onSecondary]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
@ -66,7 +62,7 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<button onClick={handleCancel} className='link-button'>
<button onClick={onClose} className='link-button'>
{cancel ?? (
<FormattedMessage
id='confirmation_modal.cancel'

View File

@ -11,3 +11,4 @@ export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list';
export { ConfirmMissingAltTextModal } from './missing_alt_text';
export { ConfirmRevokeQuoteModal } from './revoke_quote';
export { QuietPostQuoteInfoModal } from './quiet_post_quote_info';

View File

@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { quoteCompose } from '@/flavours/glitch/actions/compose_typed';
import { closeModal } from '@/flavours/glitch/actions/modal';
import { changeSetting } from '@/flavours/glitch/actions/settings';
import type { Status } from '@/flavours/glitch/models/status';
import { useAppDispatch } from '@/flavours/glitch/store';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
title: {
id: 'confirmations.quiet_post_quote_info.title',
defaultMessage: 'Quoting quiet public posts',
},
message: {
id: 'confirmations.quiet_post_quote_info.message',
defaultMessage:
'When quoting a quiet public post, your post will be hidden from trending timelines.',
},
got_it: {
id: 'confirmations.quiet_post_quote_info.got_it',
defaultMessage: 'Got it',
},
dismiss: {
id: 'confirmations.quiet_post_quote_info.dismiss',
defaultMessage: "Don't remind me again",
},
});
/**
* [1] Since we only want this modal to have two buttons "Don't ask again" and
* "Got it" , we have to use the `onClose` handler to handle the "Don't ask again"
* functionality. Because of this, we need to set `closeWhenConfirm` to false and
* close the modal manually.
* This prevents the modal from being dismissed permanently when just confirming.
*/
export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
status,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const confirm = useCallback(() => {
dispatch(quoteCompose(status));
// [1]
dispatch(
closeModal({ modalType: 'CONFIRM_QUIET_QUOTE', ignoreFocus: true }),
);
}, [dispatch, status]);
const dismiss = useCallback(() => {
dispatch(quoteCompose(status));
dispatch(
changeSetting(['dismissed_banners', 'quote/quiet_post_hint'], true),
);
// [1]
dispatch(
closeModal({ modalType: 'CONFIRM_QUIET_QUOTE', ignoreFocus: true }),
);
}, [dispatch, status]);
return (
<ConfirmationModal
closeWhenConfirm={false} // [1]
title={intl.formatMessage(messages.title)}
message={intl.formatMessage(messages.message)}
confirm={intl.formatMessage(messages.got_it)}
cancel={intl.formatMessage(messages.dismiss)}
onConfirm={confirm}
onClose={dismiss}
/>
);
};

View File

@ -201,8 +201,6 @@ class MediaModal extends ImmutablePureComponent {
preview={image.get('preview_url')}
blurhash={image.get('blurhash')}
src={image.get('url')}
width={image.get('width')}
height={image.get('height')}
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
startTime={currentTime || 0}
startPlaying={autoPlay || false}
@ -218,8 +216,6 @@ class MediaModal extends ImmutablePureComponent {
return (
<GIFV
src={image.get('url')}
width={width}
height={height}
key={image.get('url')}
alt={description}
lang={lang}

View File

@ -39,6 +39,7 @@ import {
ConfirmFollowToListModal,
ConfirmMissingAltTextModal,
ConfirmRevokeQuoteModal,
QuietPostQuoteInfoModal,
} from './confirmation_modals';
import DeprecatedSettingsModal from './deprecated_settings_modal';
import DoodleModal from './doodle_modal';
@ -68,6 +69,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
'MUTE': MuteModal,
'BLOCK': BlockModal,
'DOMAIN_BLOCK': DomainBlockModal,

View File

@ -163,7 +163,6 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
value: 'unlisted',
text: intl.formatMessage(privacyMessages.unlisted_short),
meta: intl.formatMessage(privacyMessages.unlisted_long),
extra: intl.formatMessage(privacyMessages.unlisted_extra),
icon: 'unlock',
iconComponent: QuietTimeIcon,
},
@ -199,8 +198,11 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
onClose();
}, [onChange, onClose, visibility, quotePolicy]);
const privacyDropdownId = useId();
const quoteDropdownId = useId();
const uniqueId = useId();
const visibilityLabelId = `${uniqueId}-visibility-label`;
const visibilityDescriptionId = `${uniqueId}-visibility-desc`;
const quoteLabelId = `${uniqueId}-quote-label`;
const quoteDescriptionId = `${uniqueId}-quote-desc`;
return (
<div className='modal-root__modal dialog-modal visibility-modal'>
@ -235,28 +237,36 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
/>
</div>
<div className='dialog-modal__content__form'>
<label
htmlFor={privacyDropdownId}
className={classNames('visibility-dropdown__label', {
<div
className={classNames('visibility-dropdown', {
disabled: disableVisibility,
})}
>
<FormattedMessage
id='visibility_modal.privacy_label'
defaultMessage='Visibility'
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
className='visibility-dropdown__label'
id={visibilityLabelId}
>
<FormattedMessage
id='visibility_modal.privacy_label'
defaultMessage='Visibility'
/>
</label>
<Dropdown
items={visibilityItems}
classPrefix='visibility-dropdown'
current={visibility}
onChange={handleVisibilityChange}
title={intl.formatMessage(privacyMessages.change_privacy)}
labelId={visibilityLabelId}
descriptionId={visibilityDescriptionId}
classPrefix='visibility-dropdown'
disabled={disableVisibility}
id={privacyDropdownId}
/>
{!!statusId && (
<p className='visibility-dropdown__helper'>
<p
className='visibility-dropdown__helper'
id='visibilityDescriptionId'
>
<FormattedMessage
id='visibility_modal.helper.privacy_editing'
defaultMessage="Visibility can't be changed after a post is published."
@ -264,37 +274,47 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
</p>
)}
{!statusId && disablePublicVisibilities && (
<p className='visibility-dropdown__helper'>
<p
className='visibility-dropdown__helper'
id='visibilityDescriptionId'
>
<FormattedMessage
id='visibility_modal.helper.privacy_private_self_quote'
defaultMessage='Self-quotes of private posts cannot be made public.'
/>
</p>
)}
</label>
</div>
<label
htmlFor={quoteDropdownId}
className={classNames('visibility-dropdown__label', {
<div
className={classNames('visibility-dropdown', {
disabled: disableQuotePolicy,
})}
>
<FormattedMessage
id='visibility_modal.quote_label'
defaultMessage='Who can quote'
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='visibility-dropdown__label' id={quoteLabelId}>
<FormattedMessage
id='visibility_modal.quote_label'
defaultMessage='Who can quote'
/>
</label>
<Dropdown
items={quoteItems}
onChange={handleQuotePolicyChange}
classPrefix='visibility-dropdown'
current={disableQuotePolicy ? 'nobody' : quotePolicy}
title={intl.formatMessage(messages.buttonTitle)}
onChange={handleQuotePolicyChange}
labelId={quoteLabelId}
descriptionId={quoteDescriptionId}
classPrefix='visibility-dropdown'
disabled={disableQuotePolicy}
id={quoteDropdownId}
/>
<QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
</label>
<QuotePolicyHelper
policy={quotePolicy}
visibility={visibility}
className='visibility-dropdown__helper'
id={quoteDescriptionId}
/>
</div>
</div>
<div className='dialog-modal__content__actions'>
<Button onClick={onClose} secondary>
@ -317,42 +337,44 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
);
VisibilityModal.displayName = 'VisibilityModal';
const QuotePolicyHelper: FC<{
policy: ApiQuotePolicy;
visibility: StatusVisibility;
}> = ({ policy, visibility }) => {
const QuotePolicyHelper: FC<
{
policy: ApiQuotePolicy;
visibility: StatusVisibility;
} & React.ComponentPropsWithoutRef<'p'>
> = ({ policy, visibility, ...otherProps }) => {
let hintText: React.ReactElement | undefined;
if (visibility === 'unlisted' && policy !== 'nobody') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.unlisted_quoting'
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.unlisted_quoting'
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
/>
);
}
if (visibility === 'private') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.private_quoting'
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.private_quoting'
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
/>
);
}
if (visibility === 'direct') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.direct_quoting'
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.direct_quoting'
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
/>
);
}
return null;
if (!hintText) {
return null;
}
return <p {...otherProps}>{hintText}</p>;
};

View File

@ -94,8 +94,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']),
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
isWide: state.getIn(['local_settings', 'stretch']),
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
@ -252,8 +251,7 @@ class UI extends PureComponent {
fullWidthColumns: PropTypes.bool,
systemFontUi: PropTypes.bool,
isComposing: PropTypes.bool,
hasComposingText: PropTypes.bool,
hasMediaAttachments: PropTypes.bool,
hasComposingContents: PropTypes.bool,
canUploadMore: PropTypes.bool,
intl: PropTypes.object.isRequired,
unreadNotifications: PropTypes.number,
@ -272,11 +270,11 @@ class UI extends PureComponent {
};
handleBeforeUnload = e => {
const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
const { intl, dispatch, hasComposingContents } = this.props;
dispatch(synchronouslySubmitMarkers());
if (hasComposingText || hasMediaAttachments) {
if (hasComposingContents) {
// Setting returnValue to any string causes confirmation dialog.
// Many browsers no longer display this text to users,
// but we set user-friendly message for other browsers, e.g. Edge.

View File

@ -1,7 +1,13 @@
import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react';
import type { IconProp } from '../components/icon';
interface BaseMenuItem {
text: string;
description?: string;
icon?: IconProp;
highlighted?: boolean;
disabled?: boolean;
dangerous?: boolean;
}

View File

@ -103,9 +103,9 @@ const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
export const rootReducer = combineReducers(reducers, RootStateRecord);
export function reducerWithInitialState(
stateOverrides: Record<string, unknown> = {},
...stateOverrides: Record<string, unknown>[]
) {
const initialStateRecord = mergeDeep(initialRootState, stateOverrides);
const initialStateRecord = mergeDeep(initialRootState, ...stateOverrides);
const PatchedRootStateRecord = ImmutableRecord(
initialStateRecord,
'RootState',

View File

@ -65,6 +65,9 @@ export const searchReducer = createReducer(initialState, (builder) => {
(state, action) => {
state.type = action.meta.arg.type;
state.loading = true;
if (action.type === submitSearch.pending.type) {
state.results = undefined;
}
},
);

View File

@ -124,6 +124,8 @@ const initialState = ImmutableMap({
'explore/links': false,
'explore/statuses': false,
'explore/tags': false,
'notifications/remove_quote_hint': false,
'quote/quiet_post_hint': false,
}),
});

View File

@ -201,6 +201,41 @@
}
}
&.button--plain {
color: $highlight-text-color;
background: transparent;
padding: 6px;
// The button has no outline, so we use negative margin to
// visually align its label with its surroundings while maintaining
// a generous click target
margin-inline: -6px;
border: 1px solid transparent;
&:active,
&:focus,
&:hover {
border-color: transparent;
color: lighten($highlight-text-color, 4%);
background-color: transparent;
text-decoration: none;
}
&:disabled,
&.disabled {
opacity: 0.7;
border-color: transparent;
color: $ui-button-disabled-color;
&:active,
&:focus,
&:hover {
border-color: transparent;
color: $ui-button-disabled-color;
}
}
}
&.button-tertiary {
background: transparent;
padding: 6px 17px;
@ -436,8 +471,8 @@
}
}
body > [data-popper-placement] {
z-index: 3;
[data-popper-placement] {
z-index: 9999;
}
.invisible {
@ -2904,10 +2939,26 @@ a.account__display-name {
color: $error-value-color;
}
&--highlighted {
color: $highlight-text-color;
}
&-content {
display: flex;
flex-direction: column;
}
&-subtitle {
font-weight: 400;
}
a,
button {
font: inherit;
display: block;
display: flex;
align-items: center;
gap: 8px;
white-space: inherit;
width: 100%;
padding: 6px 14px; // glitch: reduced padding
border: 0;
@ -2916,9 +2967,6 @@ a.account__display-name {
box-sizing: border-box;
text-decoration: none;
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: inherit;
border-radius: 4px;
@ -2938,30 +2986,8 @@ a.account__display-name {
}
}
.reblog-button {
&__item {
max-width: 360px;
button {
display: flex;
align-items: center;
gap: 8px;
white-space: inherit;
}
div {
display: flex;
flex-direction: column;
}
&.active:not(.disabled) {
color: $highlight-text-color;
}
}
&__meta {
font-weight: 400;
}
.reblog-menu-item {
max-width: 360px;
}
.inline-account {
@ -5606,7 +5632,6 @@ a.status-card {
border-radius: 4px;
overflow: hidden;
z-index: 2;
width: 300px;
&.top {
transform-origin: 50% 100%;
@ -5818,6 +5843,7 @@ a.status-card {
.language-dropdown {
&__dropdown {
width: 300px;
padding: 0;
.emoji-mart-search {
@ -5905,26 +5931,23 @@ a.status-card {
z-index: 9999;
}
&.disabled {
opacity: 0.6;
cursor: default;
}
&__label {
cursor: pointer;
display: block;
> span {
display: block;
font-weight: 500;
margin-bottom: 8px;
}
&.disabled {
cursor: default;
opacity: 0.5;
}
font-weight: 500;
margin-bottom: 8px;
}
&__button {
display: flex;
align-items: center;
color: $primary-text-color;
background: var(--dropdown-background-color);
border: 1px solid var(--dropdown-border-color);
background: var(--input-background-color);
border: 1px solid var(--background-border-color);
padding: 8px 12px;
width: 100%;
text-align: left;
@ -5937,6 +5960,13 @@ a.status-card {
}
}
&__icon {
margin-inline: auto -4px;
width: 18px;
height: 18px;
opacity: 0.5;
}
&__helper {
margin-top: 4px;
font-size: 0.8em;
@ -7360,7 +7390,8 @@ img.modal-warning {
cursor: default;
}
.media-gallery__alt__popover {
.info-tooltip {
color: $white;
background: color.change($black, $alpha: 0.65);
backdrop-filter: $backdrop-blur-filter;
border-radius: 4px;
@ -7372,20 +7403,36 @@ img.modal-warning {
max-height: 30em;
overflow-y: auto;
&--solid {
color: var(--nested-card-text);
background:
/* This is a bit of a silly hack for layering two background colours
* since --nested-card-background is too transparent for a tooltip */
linear-gradient(
var(--nested-card-background),
var(--nested-card-background)
),
linear-gradient(var(--background-color), var(--background-color));
border: var(--nested-card-border);
}
h4 {
font-size: 15px;
line-height: 20px;
font-weight: 500;
color: $white;
margin-bottom: 8px;
}
p {
font-size: 15px;
line-height: 20px;
color: color.change($white, $alpha: 0.85);
opacity: 0.85;
white-space: pre-line;
}
.button {
margin-block-start: 8px;
}
}
.attachment-list {

View File

@ -7,7 +7,7 @@ body {
--dropdown-border-color: hsl(240deg, 25%, 88%);
--dropdown-background-color: #fff;
--modal-border-color: hsl(240deg, 25%, 88%);
--modal-background-color: var(--background-color-tint);
--modal-background-color: var(--background-color);
--background-border-color: hsl(240deg, 25%, 88%);
--background-color: #fff;
--background-color-tint: rgba(255, 255, 255, 80%);

View File

@ -0,0 +1,16 @@
/**
* Extend an existing type and make some of its properties required or optional.
* @example
* interface Person {
* name: string;
* age?: number;
* likesIceCream?: boolean;
* }
*
* type PersonWithSomeRequired = SomeRequired<Person, 'age' | 'likesIceCream' >;
* type PersonWithSomeOptional = SomeOptional<Person, 'name' >;
*/
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>;

View File

@ -16,6 +16,7 @@ import type { Status } from '../models/status';
import { showAlert } from './alerts';
import { focusCompose } from './compose';
import { openModal } from './modal';
const messages = defineMessages({
quoteErrorUpload: {
@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk(
export const quoteComposeByStatus = createAppThunk(
(status: Status, { dispatch, getState }) => {
const composeState = getState().compose;
const state = getState();
const composeState = state.compose;
const mediaAttachments = composeState.get('media_attachments');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const wasQuietPostHintModalDismissed: boolean =
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
state.settings.getIn(
['dismissed_banners', 'quote/quiet_post_hint'],
false,
);
if (composeState.get('poll')) {
dispatch(showAlert({ message: messages.quoteErrorPoll }));
@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk(
status.getIn(['quote_approval', 'current_user']) !== 'manual'
) {
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
} else if (
status.get('visibility') === 'unlisted' &&
!wasQuietPostHintModalDismissed
) {
dispatch(
openModal({
modalType: 'CONFIRM_QUIET_QUOTE',
modalProps: { status },
}),
);
} else {
dispatch(quoteCompose(status));
}

View File

@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
return normalResult;
}
function stripQuoteFallback(text) {
const wrapper = document.createElement('div');
wrapper.innerHTML = text;
wrapper.querySelector('.quote-inline')?.remove();
return wrapper.innerHTML;
}
export function normalizeStatus(status, normalOldStatus) {
const normalStatus = { ...status };
@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
if (normalStatus.quote) {
normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml);
}
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
normalStatus.url = null;
}
@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) {
spoiler_text: translation.spoiler_text,
};
// Remove quote fallback link from the DOM so it doesn't mess with paragraph margins
if (status.get('quote')) {
normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml);
}
return normalTranslation;
}

View File

@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
const offset = [0, 4] as OffsetValue;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
export const AltTextBadge: React.FC<{
description: string;
}> = ({ description }) => {
export const AltTextBadge: React.FC<{ description: string }> = ({
description,
}) => {
const accessibilityId = useId();
const anchorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
{({ props }) => (
<div {...props} className='hover-card-controller'>
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
className='media-gallery__alt__popover dropdown-animation'
className='info-tooltip dropdown-animation'
role='region'
id={accessibilityId}
onMouseDown={handleMouseDown}

View File

@ -8,6 +8,7 @@ const meta = {
component: Button,
args: {
secondary: false,
plain: false,
compact: false,
dangerous: false,
disabled: false,
@ -57,6 +58,14 @@ export const Secondary: Story = {
play: buttonTest,
};
export const Plain: Story = {
args: {
plain: true,
children: 'Plain button',
},
play: buttonTest,
};
export const Compact: Story = {
args: {
compact: true,
@ -101,6 +110,14 @@ export const SecondaryDisabled: Story = {
play: disabledButtonTest,
};
export const PlainDisabled: Story = {
args: {
...Plain.args,
disabled: true,
},
play: disabledButtonTest,
};
const loadingButtonTest: Story['play'] = async ({
args,
canvas,

View File

@ -9,6 +9,7 @@ interface BaseProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
block?: boolean;
secondary?: boolean;
plain?: boolean;
compact?: boolean;
dangerous?: boolean;
loading?: boolean;
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
disabled,
block,
secondary,
plain,
compact,
dangerous,
loading,
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
<button
className={classNames('button', className, {
'button-secondary': secondary,
'button--plain': plain,
'button--compact': compact,
'button--block': block,
'button--dangerous': dangerous,

View File

@ -1,8 +1,3 @@
/* eslint-disable @typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/no-unsafe-member-access
-- the settings store is not yet typed */
import type { PropsWithChildren } from 'react';
import { useCallback, useState, useEffect } from 'react';
@ -23,31 +18,48 @@ interface Props {
id: string;
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const dismissed = useAppSelector((state) =>
export function useDismissableBannerState({ id }: Props) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const dismissed: boolean = useAppSelector((state) =>
/* eslint-disable-next-line */
state.settings.getIn(['dismissed_banners', id], false),
);
const [isVisible, setIsVisible] = useState(
!bannerSettings.get(id) && !dismissed,
);
const dispatch = useAppDispatch();
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
const intl = useIntl();
const handleDismiss = useCallback(() => {
setVisible(false);
const dismiss = useCallback(() => {
setIsVisible(false);
bannerSettings.set(id, true);
dispatch(changeSetting(['dismissed_banners', id], true));
}, [id, dispatch]);
useEffect(() => {
if (!visible && !dismissed) {
// Store legacy localStorage setting on server
if (!isVisible && !dismissed) {
dispatch(changeSetting(['dismissed_banners', id], true));
}
}, [id, dispatch, visible, dismissed]);
}, [id, dispatch, isVisible, dismissed]);
if (!visible) {
return {
isVisible,
dismiss,
};
}
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
id,
children,
}) => {
const intl = useIntl();
const { isVisible, dismiss } = useDismissableBannerState({
id,
});
if (!isVisible) {
return null;
}
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
icon='times'
iconComponent={CloseIcon}
title={intl.formatMessage(messages.dismiss)}
onClick={handleDismiss}
onClick={dismiss}
/>
</div>

View File

@ -0,0 +1,81 @@
import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState } from '@/testing/factories';
import { DisplayName, LinkedDisplayName } from './index';
type PageProps = Omit<ComponentProps<typeof DisplayName>, 'account'> & {
name: string;
username: string;
loading: boolean;
};
const meta = {
title: 'Components/DisplayName',
args: {
username: 'mastodon@mastodon.social',
name: 'Test User 🧪',
loading: false,
simple: false,
noDomain: false,
localDomain: 'mastodon.social',
},
tags: [],
render({ name, username, loading, ...args }) {
const account = !loading
? accountFactoryState({
display_name: name,
acct: username,
})
: undefined;
return <DisplayName {...args} account={account} />;
},
} satisfies Meta<PageProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {},
};
export const Loading: Story = {
args: {
loading: true,
},
};
export const NoDomain: Story = {
args: {
noDomain: true,
},
};
export const Simple: Story = {
args: {
simple: true,
},
};
export const LocalUser: Story = {
args: {
username: 'localuser',
name: 'Local User',
localDomain: '',
},
};
export const Linked: Story = {
render({ name, username, loading, ...args }) {
const account = !loading
? accountFactoryState({
display_name: name,
acct: username,
})
: undefined;
return <LinkedDisplayName {...args} account={account} />;
},
};

View File

@ -0,0 +1,122 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { useMemo } from 'react';
import classNames from 'classnames';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import type { Account } from '@/mastodon/models/account';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { Skeleton } from '../skeleton';
interface Props {
account?: Account;
localDomain?: string;
simple?: boolean;
noDomain?: boolean;
}
export const DisplayName: FC<Props & ComponentPropsWithoutRef<'span'>> = ({
account,
localDomain,
simple = false,
noDomain = false,
className,
...props
}) => {
const username = useMemo(() => {
if (!account || noDomain) {
return null;
}
let acct = account.get('acct');
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}
return `@${acct}`;
}, [account, localDomain, noDomain]);
if (!account) {
if (simple) {
return null;
}
return (
<span {...props} className={classNames('display-name', className)}>
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
{!noDomain && (
<span className='display-name__account'>
&nbsp;
<Skeleton width='7ch' />
</span>
)}
</span>
);
}
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
if (simple) {
return (
<bdi>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
</bdi>
);
}
return (
<span {...props} className={classNames('display-name', className)}>
<bdi>
<EmojiHTML
className='display-name__html'
htmlString={accountName}
shallow
as='strong'
/>
</bdi>
{username && (
<span className='display-name__account'>&nbsp;{username}</span>
)}
</span>
);
};
export const LinkedDisplayName: FC<
Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial<LinkProps>
> = ({
account,
asProps = {},
className,
localDomain,
simple,
noDomain,
...linkProps
}) => {
const displayProps = {
account,
className,
localDomain,
simple,
noDomain,
...asProps,
};
if (!account) {
return <DisplayName {...displayProps} />;
}
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
{...linkProps}
>
<DisplayName {...displayProps} />
</Link>
);
};

View File

@ -1,22 +1,28 @@
import { useCallback, useId, useMemo, useRef, useState } from 'react';
import type { ComponentPropsWithoutRef, FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
import type { SelectItem } from '../dropdown_selector';
import { DropdownSelector } from '../dropdown_selector';
import { Icon } from '../icon';
import { matchWidth } from './utils';
interface DropdownProps {
title: string;
disabled?: boolean;
items: SelectItem[];
onChange: (value: string) => void;
current: string;
labelId: string;
descriptionId?: string;
emptyText?: MessageDescriptor;
classPrefix: string;
}
@ -24,39 +30,59 @@ interface DropdownProps {
export const Dropdown: FC<
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
> = ({
title,
disabled,
items,
current,
onChange,
labelId,
descriptionId,
classPrefix,
className,
id,
...buttonProps
}) => {
const intl = useIntl();
const buttonRef = useRef<HTMLButtonElement>(null);
const accessibilityId = useId();
const uniqueId = useId();
const buttonId = id ?? `${uniqueId}-button`;
const listboxId = `${uniqueId}-listbox`;
const [open, setOpen] = useState(false);
const handleToggle = useCallback(() => {
if (!disabled) {
setOpen((prevOpen) => !prevOpen);
setOpen((prevOpen) => {
buttonRef.current?.focus();
return !prevOpen;
});
}
}, [disabled]);
const handleClose = useCallback(() => {
setOpen(false);
buttonRef.current?.focus();
}, []);
const currentText = useMemo(
() => items.find((i) => i.value === current)?.text,
[current, items],
() =>
items.find((i) => i.value === current)?.text ??
intl.formatMessage({
id: 'dropdown.empty',
defaultMessage: 'Select an option',
}),
[current, intl, items],
);
return (
<>
<button
type='button'
{...buttonProps}
title={title}
id={buttonId}
aria-labelledby={`${labelId} ${buttonId}`}
aria-describedby={descriptionId}
aria-expanded={open}
aria-controls={accessibilityId}
aria-controls={listboxId}
onClick={handleToggle}
disabled={disabled}
className={classNames(
@ -69,23 +95,24 @@ export const Dropdown: FC<
)}
ref={buttonRef}
>
{currentText ?? (
<FormattedMessage
id='dropdown.empty'
defaultMessage='Select an option'
/>
)}
{currentText}
<Icon
id='unfold-icon'
icon={UnfoldMoreIcon}
className={`${classPrefix}__icon`}
/>
</button>
<Overlay
show={open}
offset={[0, 4]}
offset={[0, 0]}
placement='bottom-start'
onHide={handleClose}
flip
target={buttonRef.current}
popperConfig={{
strategy: 'fixed',
modifiers: [matchWidth],
}}
>
{({ props, placement }) => (
@ -96,7 +123,7 @@ export const Dropdown: FC<
`${classPrefix}__dropdown`,
placement,
)}
id={accessibilityId}
id={listboxId}
>
<DropdownSelector
items={items}

View File

@ -0,0 +1,17 @@
import type { Modifier, UsePopperState } from 'react-overlays/esm/usePopper';
export const matchWidth: Modifier<'sameWidth', UsePopperState> = {
name: 'sameWidth',
enabled: true,
phase: 'beforeWrite',
requires: ['computeStyles'],
fn: ({ state }) => {
if (state.styles.popper) {
state.styles.popper.width = `${state.rects.reference.width}px`;
}
},
effect: ({ state }) => {
const reference = state.elements.reference as HTMLElement;
state.elements.popper.style.width = `${reference.offsetWidth}px`;
},
};

View File

@ -36,6 +36,7 @@ import {
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { Icon } from './icon';
import type { IconProp } from './icon';
import { IconButton } from './icon_button';
@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
onItemClick?: ItemClickFn<Item>;
}
export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
item,
}) => {
if (item === null) {
return null;
}
const { text, description, icon } = item;
return (
<>
{icon && <Icon icon={icon} id={`${text}-icon`} />}
<span className='dropdown-menu__item-content'>
{text}
{Boolean(description) && (
<span className='dropdown-menu__item-subtitle'>{description}</span>
)}
</span>
</>
);
};
export const DropdownMenu = <Item = MenuItem,>({
items,
loading,
@ -200,7 +222,7 @@ export const DropdownMenu = <Item = MenuItem,>({
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
@ -211,8 +233,9 @@ export const DropdownMenu = <Item = MenuItem,>({
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
disabled={disabled}
>
{text}
<DropdownMenuItemContent item={option} />
</button>
);
} else if (isExternalLinkItem(option)) {
@ -227,7 +250,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</a>
);
} else {
@ -239,7 +262,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</Link>
);
}
@ -247,6 +270,7 @@ export const DropdownMenu = <Item = MenuItem,>({
return (
<li
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--highlighted': highlighted,
'dropdown-menu__item--dangerous': dangerous,
})}
key={`${text}-${i}`}

View File

@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
onClose,
onChange,
}) => {
const nodeRef = useRef<HTMLUListElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const focusedItemRef = useRef<HTMLLIElement>(null);
const [currentValue, setCurrentValue] = useState(value);
const handleDocumentClick = useCallback(
(e: MouseEvent | TouchEvent) => {
if (
nodeRef.current &&
e.target instanceof Node &&
!nodeRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
},
[nodeRef, onClose],
);
const handleClick = useCallback(
(
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
break;
case 'ArrowDown':
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
listRef.current?.children[index + 1] ??
listRef.current?.firstElementChild;
break;
case 'ArrowUp':
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
listRef.current?.children[index - 1] ??
listRef.current?.lastElementChild;
break;
case 'Tab':
if (e.shiftKey) {
element =
nodeRef.current?.children[index - 1] ??
nodeRef.current?.lastElementChild;
listRef.current?.children[index - 1] ??
listRef.current?.lastElementChild;
} else {
element =
nodeRef.current?.children[index + 1] ??
nodeRef.current?.firstElementChild;
listRef.current?.children[index + 1] ??
listRef.current?.firstElementChild;
}
break;
case 'Home':
element = nodeRef.current?.firstElementChild;
element = listRef.current?.firstElementChild;
break;
case 'End':
element = nodeRef.current?.lastElementChild;
element = listRef.current?.lastElementChild;
break;
}
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
e.stopPropagation();
}
},
[nodeRef, items, onClose, handleClick, setCurrentValue],
[items, onClose, handleClick, setCurrentValue],
);
useEffect(() => {
const handleDocumentClick = (e: MouseEvent | TouchEvent) => {
if (
listRef.current &&
e.target instanceof Node &&
!listRef.current.contains(e.target)
) {
onClose();
e.stopPropagation();
}
};
document.addEventListener('click', handleDocumentClick, { capture: true });
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
focusedItemRef.current?.focus({ preventScroll: true });
return () => {
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
listenerOptions,
);
};
}, [handleDocumentClick]);
}, [onClose]);
return (
<ul style={style} role='listbox' ref={nodeRef}>
<ul style={style} role='listbox' ref={listRef}>
{items.map((item) => (
<li
role='option'

View File

@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { statusFactoryState } from '@/testing/factories';
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
interface StoryProps {
visibility: StatusVisibility;
@ -13,7 +13,7 @@ interface StoryProps {
}
const meta = {
title: 'Components/Status/ReblogButton',
title: 'Components/Status/BoostButton',
args: {
visibility: 'public',
quoteAllowed: true,
@ -38,7 +38,7 @@ const meta = {
},
},
render: (args) => (
<StatusReblogButton
<StatusBoostButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>

View File

@ -0,0 +1,253 @@
import { useCallback, useMemo } from 'react';
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status } from '@/mastodon/models/status';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import type { SomeRequired } from '@/mastodon/utils/types';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu';
import { IconButton } from '../icon_button';
import {
boostItemState,
messages,
quoteItemState,
selectStatusState,
} from './boost_button_utils';
const renderMenuItem: RenderItemFn<ActionMenuItem> = (
item,
index,
handlers,
focusRefCallback,
) => (
<ReblogMenuItem
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
);
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
export const StatusBoostButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const {
isLoggedIn,
isReblogged,
isReblogAllowed,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
} = statusState;
const isMenuDisabled =
!isQuoteAutomaticallyAccepted &&
!isQuoteManuallyAccepted &&
!isReblogAllowed;
const statusId = status.get('id') as string;
const wasBoosted = !!status.get('reblogged');
const items = useMemo(() => {
const boostItem = boostItemState(statusState);
const quoteItem = quoteItemState(statusState);
return [
{
text: intl.formatMessage(boostItem.title),
description: boostItem.meta
? intl.formatMessage(boostItem.meta)
: undefined,
icon: boostItem.iconComponent,
highlighted: wasBoosted,
disabled: boostItem.disabled,
action: (event) => {
if (isLoggedIn) {
dispatch(toggleReblog(statusId, event.shiftKey));
}
},
},
{
text: intl.formatMessage(quoteItem.title),
description: quoteItem.meta
? intl.formatMessage(quoteItem.meta)
: undefined,
icon: quoteItem.iconComponent,
disabled: quoteItem.disabled,
action: () => {
if (isLoggedIn) {
dispatch(quoteComposeById(statusId));
}
},
},
] satisfies [ActionMenuItemWithIcon, ActionMenuItemWithIcon];
}, [dispatch, intl, isLoggedIn, statusId, statusState, wasBoosted]);
const boostIcon = items[0].icon;
const handleDropdownOpen = useCallback(
(event: MouseEvent | KeyboardEvent) => {
if (!isLoggedIn) {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
} else if (event.shiftKey) {
dispatch(toggleReblog(status.get('id'), true));
return false;
}
return true;
},
[dispatch, isLoggedIn, status],
);
return (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={isMenuDisabled}
>
<IconButton
title={intl.formatMessage(
isMenuDisabled ? messages.all_disabled : messages.reblog_or_quote,
)}
icon='retweet'
iconComponent={boostIcon}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
index,
item,
handlers,
focusRefCallback,
}) => {
const { text, highlighted, disabled } = item;
return (
<li
className={classNames('dropdown-menu__item reblog-menu-item', {
'dropdown-menu__item--highlighted': highlighted,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<DropdownMenuItemContent item={item} />
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const BoostButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusBoostButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() => boostItemState(statusState),
[statusState],
);
const dispatch = useAppDispatch();
const handleClick: MouseEventHandler = useCallback(
(event) => {
if (statusState.isLoggedIn) {
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, statusState.isLoggedIn],
);
return (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};

View File

@ -0,0 +1,161 @@
import { defineMessages } from 'react-intl';
import type { MessageDescriptor } from 'react-intl';
import type { Status, StatusVisibility } from '@/mastodon/models/status';
import { createAppSelector } from '@/mastodon/store';
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.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 type { IconProp } from '../icon';
export const messages = defineMessages({
all_disabled: {
id: 'status.all_disabled',
defaultMessage: 'Boosts and quotes are disabled',
},
quote: { id: 'status.quote', defaultMessage: 'Quote' },
quote_cannot: {
id: 'status.cannot_quote',
defaultMessage: 'Quotes are disabled on this post',
},
quote_followers_only: {
id: 'status.quote_followers_only',
defaultMessage: 'Only followers can quote this post',
},
quote_manual_review: {
id: 'status.quote_manual_review',
defaultMessage: 'Author will manually review',
},
quote_private: {
id: 'status.quote_private',
defaultMessage: 'Private posts cannot be quoted',
},
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_or_quote: {
id: 'status.reblog_or_quote',
defaultMessage: 'Boost or quote',
},
reblog_cancel: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Share again with your followers',
},
reblog_cannot: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
request_quote: {
id: 'status.request_quote',
defaultMessage: 'Request to quote',
},
});
export const selectStatusState = createAppSelector(
[
(state) => state.meta.get('me') as string | undefined,
(_, status: Status) => status,
],
(userId, status) => {
const isPublic = ['public', 'unlisted'].includes(
status.get('visibility') as StatusVisibility,
);
const isMineAndPrivate =
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private';
return {
isLoggedIn: !!userId,
isPublic,
isMine: userId === status.getIn(['account', 'id']),
isPrivateReblog:
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private',
isReblogged: !!status.get('reblogged'),
isReblogAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
(isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
(isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
};
},
);
export type StatusState = ReturnType<typeof selectStatusState>;
export interface MenuItemState {
title: MessageDescriptor;
meta?: MessageDescriptor;
iconComponent: IconProp;
disabled?: boolean;
}
export function boostItemState({
isPublic,
isPrivateReblog,
isReblogged,
}: StatusState): MenuItemState {
if (isReblogged) {
return {
title: messages.reblog_cancel,
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
};
}
const iconText: MenuItemState = {
title: messages.reblog,
iconComponent: RepeatIcon,
};
if (isPrivateReblog) {
iconText.meta = messages.reblog_private;
iconText.iconComponent = RepeatPrivateIcon;
} else if (!isPublic) {
iconText.meta = messages.reblog_cannot;
iconText.iconComponent = RepeatDisabledIcon;
iconText.disabled = true;
}
return iconText;
}
export function quoteItemState({
isMine,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
isQuoteFollowersOnly,
isPublic,
}: StatusState): MenuItemState {
const iconText: MenuItemState = {
title: messages.quote,
iconComponent: FormatQuote,
};
if (!isPublic && !isMine) {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = messages.quote_private;
} else if (isQuoteAutomaticallyAccepted) {
iconText.title = messages.quote;
} else if (isQuoteManuallyAccepted) {
iconText.title = messages.request_quote;
iconText.meta = messages.quote_manual_review;
} else {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = isQuoteFollowersOnly
? messages.quote_followers_only
: messages.quote_cannot;
}
return iconText;
}

View File

@ -1,425 +0,0 @@
import { useCallback, useMemo } from 'react';
import type {
FC,
KeyboardEvent,
MouseEvent,
MouseEventHandler,
SVGProps,
} from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status, StatusVisibility } from '@/mastodon/models/status';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.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 type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
import { Dropdown } from '../dropdown_menu';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
const messages = defineMessages({
all_disabled: {
id: 'status.all_disabled',
defaultMessage: 'Boosts and quotes are disabled',
},
quote: { id: 'status.quote', defaultMessage: 'Quote' },
quote_cannot: {
id: 'status.cannot_quote',
defaultMessage: 'Quotes are disabled on this post',
},
quote_followers_only: {
id: 'status.quote_followers_only',
defaultMessage: 'Only followers can quote this post',
},
quote_manual_review: {
id: 'status.quote_manual_review',
defaultMessage: 'Author will manually review',
},
quote_private: {
id: 'status.quote_private',
defaultMessage: 'Private posts cannot be quoted',
},
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_or_quote: {
id: 'status.reblog_or_quote',
defaultMessage: 'Boost or quote',
},
reblog_cancel: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Boost with original visibility',
},
reblog_cannot: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
request_quote: {
id: 'status.request_quote',
defaultMessage: 'Request to quote',
},
});
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
export const StatusReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const {
isLoggedIn,
isReblogged,
isReblogAllowed,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
} = statusState;
const { iconComponent } = useMemo(
() => reblogIconText(statusState),
[statusState],
);
const disabled =
!isQuoteAutomaticallyAccepted &&
!isQuoteManuallyAccepted &&
!isReblogAllowed;
const dispatch = useAppDispatch();
const statusId = status.get('id') as string;
const items: ActionMenuItem[] = useMemo(
() => [
{
text: 'reblog',
action: (event) => {
if (isLoggedIn) {
dispatch(toggleReblog(statusId, event.shiftKey));
}
},
},
{
text: 'quote',
action: () => {
if (isLoggedIn) {
dispatch(quoteComposeById(statusId));
}
},
},
],
[dispatch, isLoggedIn, statusId],
);
const handleDropdownOpen = useCallback(
(event: MouseEvent | KeyboardEvent) => {
if (!isLoggedIn) {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
} else if (event.shiftKey) {
dispatch(toggleReblog(status.get('id'), true));
return false;
}
return true;
},
[dispatch, isLoggedIn, status],
);
const renderMenuItem: RenderItemFn<ActionMenuItem> = useCallback(
(item, index, handlers, focusRefCallback) => (
<ReblogMenuItem
status={status}
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
),
[status],
);
return (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={disabled}
>
<IconButton
title={intl.formatMessage(
!disabled ? messages.reblog_or_quote : messages.all_disabled,
)}
icon='retweet'
iconComponent={iconComponent}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
status: Status;
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
status,
index,
item: { text },
handlers,
focusRefCallback,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() =>
text === 'quote'
? quoteIconText(statusState)
: reblogIconText(statusState),
[statusState, text],
);
const active = useMemo(
() => text === 'reblog' && !!status.get('reblogged'),
[status, text],
);
return (
<li
className={classNames('dropdown-menu__item reblog-button__item', {
disabled,
active,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
title={intl.formatMessage(title)}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<Icon
id={text === 'quote' ? 'quote' : 'retweet'}
icon={iconComponent}
/>
<div>
{intl.formatMessage(title)}
{meta && (
<span className='reblog-button__meta'>
{intl.formatMessage(meta)}
</span>
)}
</div>
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const ReblogButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusReblogButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
status,
counters,
}) => {
const intl = useIntl();
const statusState = useAppSelector((state) =>
selectStatusState(state, status),
);
const { title, meta, iconComponent, disabled } = useMemo(
() => reblogIconText(statusState),
[statusState],
);
const dispatch = useAppDispatch();
const handleClick: MouseEventHandler = useCallback(
(event) => {
if (statusState.isLoggedIn) {
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'reblog',
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, statusState.isLoggedIn],
);
return (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};
// Helpers for copy and state for status.
const selectStatusState = createAppSelector(
[
(state) => state.meta.get('me') as string | undefined,
(_, status: Status) => status,
],
(userId, status) => {
const isPublic = ['public', 'unlisted'].includes(
status.get('visibility') as StatusVisibility,
);
const isMineAndPrivate =
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private';
return {
isLoggedIn: !!userId,
isPublic,
isMine: userId === status.getIn(['account', 'id']),
isPrivateReblog:
userId === status.getIn(['account', 'id']) &&
status.get('visibility') === 'private',
isReblogged: !!status.get('reblogged'),
isReblogAllowed: isPublic || isMineAndPrivate,
isQuoteAutomaticallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'automatic' &&
(isPublic || isMineAndPrivate),
isQuoteManuallyAccepted:
status.getIn(['quote_approval', 'current_user']) === 'manual' &&
(isPublic || isMineAndPrivate),
isQuoteFollowersOnly:
status.getIn(['quote_approval', 'automatic', 0]) === 'followers' ||
status.getIn(['quote_approval', 'manual', 0]) === 'followers',
};
},
);
type StatusState = ReturnType<typeof selectStatusState>;
interface IconText {
title: MessageDescriptor;
meta?: MessageDescriptor;
iconComponent: FC<SVGProps<SVGSVGElement>>;
disabled?: boolean;
}
function reblogIconText({
isPublic,
isPrivateReblog,
isReblogged,
}: StatusState): IconText {
if (isReblogged) {
return {
title: messages.reblog_cancel,
iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon,
};
}
const iconText: IconText = {
title: messages.reblog,
iconComponent: RepeatIcon,
};
if (isPrivateReblog) {
iconText.meta = messages.reblog_private;
iconText.iconComponent = RepeatPrivateIcon;
} else if (!isPublic) {
iconText.meta = messages.reblog_cannot;
iconText.iconComponent = RepeatDisabledIcon;
iconText.disabled = true;
}
return iconText;
}
function quoteIconText({
isMine,
isQuoteAutomaticallyAccepted,
isQuoteManuallyAccepted,
isQuoteFollowersOnly,
isPublic,
}: StatusState): IconText {
const iconText: IconText = {
title: messages.quote,
iconComponent: FormatQuote,
};
if (!isPublic && !isMine) {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = messages.quote_private;
} else if (isQuoteAutomaticallyAccepted) {
iconText.title = messages.quote;
} else if (isQuoteManuallyAccepted) {
iconText.title = messages.request_quote;
iconText.meta = messages.quote_manual_review;
} else {
iconText.disabled = true;
iconText.iconComponent = FormatQuoteOff;
iconText.meta = isQuoteFollowersOnly
? messages.quote_followers_only
: messages.quote_cannot;
}
return iconText;
}

View File

@ -20,11 +20,12 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../initial_state';
import { me } from '../../initial_state';
import { IconButton } from './icon_button';
import { isFeatureEnabled } from '../utils/environment';
import { ReblogButton } from './status/reblog_button';
import { IconButton } from '../icon_button';
import { isFeatureEnabled } from '../../utils/environment';
import { BoostButton } from '../status/boost_button';
import { RemoveQuoteHint } from './remove_quote_hint';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired,
relationship: ImmutablePropTypes.record,
quotedAccountId: PropTypes.string,
contextType: PropTypes.string,
onReply: PropTypes.func,
onFavourite: PropTypes.func,
onDelete: PropTypes.func,
@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent {
};
render () {
const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props;
const { status, relationship, quotedAccountId, contextType, intl, withDismiss, withCounters, scrollKey } = this.props;
const { signedIn, permissions } = this.props.identity;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent {
const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
const isQuotingMe = quotedAccountId === me;
let menu = [];
@ -293,7 +296,7 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
menu.push(null);
if (quotedAccountId === me) {
if (isQuotingMe) {
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
}
@ -360,6 +363,8 @@ class StatusActionBar extends ImmutablePureComponent {
const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
return (
<div className='status__action-bar'>
@ -367,7 +372,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
</div>
<div className='status__action-bar__button-wrapper'>
<ReblogButton status={status} counters={withCounters} />
<BoostButton status={status} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
@ -375,17 +380,23 @@ class StatusActionBar extends ImmutablePureComponent {
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
</div>
<div className='status__action-bar__button-wrapper'>
<Dropdown
scrollKey={scrollKey}
status={status}
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
/>
</div>
<RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
{(dismissQuoteHint) => (
<Dropdown
scrollKey={scrollKey}
status={status}
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
direction='right'
title={intl.formatMessage(messages.more)}
onOpen={() => {
dismissQuoteHint();
return true;
}}
/>
)}
</RemoveQuoteHint>
</div>
);
}

View File

@ -0,0 +1,90 @@
import { useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/Overlay';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { Button } from '../button';
import { useDismissableBannerState } from '../dismissable_banner';
import { Icon } from '../icon';
const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint';
export const RemoveQuoteHint: React.FC<{
canShowHint: boolean;
className?: string;
children: (dismiss: () => void) => React.ReactNode;
}> = ({ canShowHint, className, children }) => {
const anchorRef = useRef<HTMLDivElement>(null);
const intl = useIntl();
const { isVisible, dismiss } = useDismissableBannerState({
id: DISMISSABLE_BANNER_ID,
});
return (
<div className={className} ref={anchorRef}>
{children(dismiss)}
{isVisible && canShowHint && (
<Overlay
show
flip
offset={[12, 10]}
placement='bottom-end'
target={anchorRef.current}
container={anchorRef.current}
>
{({ props, placement }) => (
<div
{...props}
className={classNames(
'info-tooltip info-tooltip--solid dropdown-animation',
placement,
)}
>
<h4>
<FormattedMessage
id='remove_quote_hint.title'
defaultMessage='Want to remove your quoted post?'
/>
</h4>
<FormattedMessage
id='remove_quote_hint.message'
defaultMessage='You can do so from the {icon} options menu.'
values={{
icon: (
<Icon
id='ellipsis-h'
icon={MoreHorizIcon}
aria-label={intl.formatMessage({
id: 'status.more',
defaultMessage: 'More',
})}
style={{ verticalAlign: 'middle' }}
/>
),
}}
>
{(text) => <p>{text}</p>}
</FormattedMessage>
<FormattedMessage
id='remove_quote_hint.button_label'
defaultMessage='Got it'
>
{(text) => (
<Button plain compact onClick={dismiss}>
{text}
</Button>
)}
</FormattedMessage>
</div>
)}
</Overlay>
)}
</div>
);
};

View File

@ -138,16 +138,6 @@ class StatusContent extends PureComponent {
onCollapsedToggle(collapsed);
}
// Remove quote fallback link from the DOM so it doesn't
// mess with paragraph margins
if (!!status.get('quote')) {
const inlineQuote = node.querySelector('.quote-inline');
if (inlineQuote) {
inlineQuote.remove();
}
}
}
handleMouseEnter = ({ currentTarget }) => {

View File

@ -18,7 +18,7 @@ export const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Hidden from Mastodon search results, trending, and public timelines' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },

View File

@ -79,10 +79,12 @@ const visibilityOptions = {
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
const intl = useIntl();
const { visibility, quotePolicy } = useAppSelector((state) => ({
visibility: state.compose.get('privacy') as StatusVisibility,
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
}));
const quotePolicy = useAppSelector(
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
);
const visibility = useAppSelector(
(state) => state.compose.get('privacy') as StatusVisibility,
);
const { icon, iconComponent } = useMemo(() => {
const option = visibilityOptions[visibility];

View File

@ -12,16 +12,21 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
};
export const ModernEmojiHTML = <Element extends ElementType>({
export const ModernEmojiHTML = ({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
...props
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;

View File

@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import { emojifyElement, emojifyText } from './render';
import type {
CustomEmojiMapArg,
EmojiAppState,
@ -15,7 +16,17 @@ import type {
} from './types';
import { stringHasAnyEmoji } from './utils';
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
interface UseEmojifyOptions {
text: string;
extraEmojis?: CustomEmojiMapArg;
deep?: boolean;
}
export function useEmojify({
text,
extraEmojis,
deep = true,
}: UseEmojifyOptions) {
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
const appState = useEmojiAppState();
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
const emojify = useCallback(
async (input: string) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const { emojifyElement } = await import('./render');
const result = await emojifyElement(wrapper, appState, extra);
let result: string | null = null;
if (deep) {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
if (await emojifyElement(wrapper, appState, extra)) {
result = wrapper.innerHTML;
}
} else {
result = await emojifyText(text, appState, extra);
}
if (result) {
setEmojifiedText(result.innerHTML);
setEmojifiedText(result);
} else {
setEmojifiedText(input);
}
},
[appState, extra],
[appState, deep, extra, text],
);
useLayoutEffect(() => {
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {

View File

@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
</div>
<div className='app-form__toggle__toggle'>
<div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options}
/>
</div>
<Dropdown
value={value}
onChange={onChange}
disabled={disabled}
aria-labelledby={labelId}
aria-describedby={descId}
options={options}
/>
</div>
</label>
);

View File

@ -33,7 +33,7 @@ const messages = defineMessages({
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Boost with original visibility',
defaultMessage: 'Share again with your followers',
},
cancel_reblog_private: {
id: 'status.cancel_reblog_private',

View File

@ -20,7 +20,7 @@ import { IconButton } from '../../../components/icon_button';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { me } from '../../../initial_state';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import { ReblogButton } from '@/mastodon/components/status/reblog_button';
import { BoostButton } from '@/mastodon/components/status/boost_button';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@ -310,7 +310,7 @@ class ActionBar extends PureComponent {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'>
<ReblogButton status={status} />
<BoostButton status={status} />
</div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>

View File

@ -1,6 +1,7 @@
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { DropdownMenuItemContent } from 'mastodon/components/dropdown_menu';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import {
isActionItem,
@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
if (isActionItem(option)) {
element = (
<button onClick={onClick} data-index={i}>
{text}
<button onClick={onClick} data-index={i} disabled={disabled}>
<DropdownMenuItemContent item={option} />
</button>
);
} else if (isExternalLinkItem(option)) {
@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{
onClick={onClick}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</a>
);
} else {
element = (
<Link to={option.to} onClick={onClick} data-index={i}>
{text}
<DropdownMenuItemContent item={option} />
</Link>
);
}
return (
<li
className={classNames({
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--dangerous': dangerous,
'dropdown-menu__item--highlighted': highlighted,
})}
key={`${text}-${i}`}
>

View File

@ -43,10 +43,6 @@ export const ConfirmationModal: React.FC<
onSecondary?.();
}, [onClose, onSecondary]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
return (
<div className='modal-root__modal safety-action-modal'>
<div className='safety-action-modal__top'>
@ -58,7 +54,7 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__bottom'>
<div className='safety-action-modal__actions'>
<button onClick={handleCancel} className='link-button'>
<button onClick={onClose} className='link-button'>
{cancel ?? (
<FormattedMessage
id='confirmation_modal.cancel'

View File

@ -11,3 +11,4 @@ export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list';
export { ConfirmMissingAltTextModal } from './missing_alt_text';
export { ConfirmRevokeQuoteModal } from './revoke_quote';
export { QuietPostQuoteInfoModal } from './quiet_post_quote_info';

View File

@ -0,0 +1,77 @@
import { useCallback } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { quoteCompose } from '@/mastodon/actions/compose_typed';
import { closeModal } from '@/mastodon/actions/modal';
import { changeSetting } from '@/mastodon/actions/settings';
import type { Status } from '@/mastodon/models/status';
import { useAppDispatch } from '@/mastodon/store';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
title: {
id: 'confirmations.quiet_post_quote_info.title',
defaultMessage: 'Quoting quiet public posts',
},
message: {
id: 'confirmations.quiet_post_quote_info.message',
defaultMessage:
'When quoting a quiet public post, your post will be hidden from trending timelines.',
},
got_it: {
id: 'confirmations.quiet_post_quote_info.got_it',
defaultMessage: 'Got it',
},
dismiss: {
id: 'confirmations.quiet_post_quote_info.dismiss',
defaultMessage: "Don't remind me again",
},
});
/**
* [1] Since we only want this modal to have two buttons "Don't ask again" and
* "Got it" , we have to use the `onClose` handler to handle the "Don't ask again"
* functionality. Because of this, we need to set `closeWhenConfirm` to false and
* close the modal manually.
* This prevents the modal from being dismissed permanently when just confirming.
*/
export const QuietPostQuoteInfoModal: React.FC<{ status: Status }> = ({
status,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const confirm = useCallback(() => {
dispatch(quoteCompose(status));
// [1]
dispatch(
closeModal({ modalType: 'CONFIRM_QUIET_QUOTE', ignoreFocus: true }),
);
}, [dispatch, status]);
const dismiss = useCallback(() => {
dispatch(quoteCompose(status));
dispatch(
changeSetting(['dismissed_banners', 'quote/quiet_post_hint'], true),
);
// [1]
dispatch(
closeModal({ modalType: 'CONFIRM_QUIET_QUOTE', ignoreFocus: true }),
);
}, [dispatch, status]);
return (
<ConfirmationModal
closeWhenConfirm={false} // [1]
title={intl.formatMessage(messages.title)}
message={intl.formatMessage(messages.message)}
confirm={intl.formatMessage(messages.got_it)}
cancel={intl.formatMessage(messages.dismiss)}
onConfirm={confirm}
onClose={dismiss}
/>
);
};

View File

@ -201,8 +201,6 @@ class MediaModal extends ImmutablePureComponent {
preview={image.get('preview_url')}
blurhash={image.get('blurhash')}
src={image.get('url')}
width={image.get('width')}
height={image.get('height')}
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
startTime={currentTime || 0}
@ -219,8 +217,6 @@ class MediaModal extends ImmutablePureComponent {
return (
<GIFV
src={image.get('url')}
width={width}
height={height}
key={image.get('url')}
alt={description}
lang={lang}

View File

@ -38,6 +38,7 @@ import {
ConfirmFollowToListModal,
ConfirmMissingAltTextModal,
ConfirmRevokeQuoteModal,
QuietPostQuoteInfoModal,
} from './confirmation_modals';
import { ImageModal } from './image_modal';
import MediaModal from './media_modal';
@ -62,6 +63,7 @@ export const MODAL_COMPONENTS = {
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
'MUTE': MuteModal,
'BLOCK': BlockModal,
'DOMAIN_BLOCK': DomainBlockModal,

View File

@ -163,7 +163,6 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
value: 'unlisted',
text: intl.formatMessage(privacyMessages.unlisted_short),
meta: intl.formatMessage(privacyMessages.unlisted_long),
extra: intl.formatMessage(privacyMessages.unlisted_extra),
icon: 'unlock',
iconComponent: QuietTimeIcon,
},
@ -199,8 +198,11 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
onClose();
}, [onChange, onClose, visibility, quotePolicy]);
const privacyDropdownId = useId();
const quoteDropdownId = useId();
const uniqueId = useId();
const visibilityLabelId = `${uniqueId}-visibility-label`;
const visibilityDescriptionId = `${uniqueId}-visibility-desc`;
const quoteLabelId = `${uniqueId}-quote-label`;
const quoteDescriptionId = `${uniqueId}-quote-desc`;
return (
<div className='modal-root__modal dialog-modal visibility-modal'>
@ -235,28 +237,36 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
/>
</div>
<div className='dialog-modal__content__form'>
<label
htmlFor={privacyDropdownId}
className={classNames('visibility-dropdown__label', {
<div
className={classNames('visibility-dropdown', {
disabled: disableVisibility,
})}
>
<FormattedMessage
id='visibility_modal.privacy_label'
defaultMessage='Visibility'
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
className='visibility-dropdown__label'
id={visibilityLabelId}
>
<FormattedMessage
id='visibility_modal.privacy_label'
defaultMessage='Visibility'
/>
</label>
<Dropdown
items={visibilityItems}
classPrefix='visibility-dropdown'
current={visibility}
onChange={handleVisibilityChange}
title={intl.formatMessage(privacyMessages.change_privacy)}
labelId={visibilityLabelId}
descriptionId={visibilityDescriptionId}
classPrefix='visibility-dropdown'
disabled={disableVisibility}
id={privacyDropdownId}
/>
{!!statusId && (
<p className='visibility-dropdown__helper'>
<p
className='visibility-dropdown__helper'
id='visibilityDescriptionId'
>
<FormattedMessage
id='visibility_modal.helper.privacy_editing'
defaultMessage="Visibility can't be changed after a post is published."
@ -264,37 +274,47 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
</p>
)}
{!statusId && disablePublicVisibilities && (
<p className='visibility-dropdown__helper'>
<p
className='visibility-dropdown__helper'
id='visibilityDescriptionId'
>
<FormattedMessage
id='visibility_modal.helper.privacy_private_self_quote'
defaultMessage='Self-quotes of private posts cannot be made public.'
/>
</p>
)}
</label>
</div>
<label
htmlFor={quoteDropdownId}
className={classNames('visibility-dropdown__label', {
<div
className={classNames('visibility-dropdown', {
disabled: disableQuotePolicy,
})}
>
<FormattedMessage
id='visibility_modal.quote_label'
defaultMessage='Who can quote'
/>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className='visibility-dropdown__label' id={quoteLabelId}>
<FormattedMessage
id='visibility_modal.quote_label'
defaultMessage='Who can quote'
/>
</label>
<Dropdown
items={quoteItems}
onChange={handleQuotePolicyChange}
classPrefix='visibility-dropdown'
current={disableQuotePolicy ? 'nobody' : quotePolicy}
title={intl.formatMessage(messages.buttonTitle)}
onChange={handleQuotePolicyChange}
labelId={quoteLabelId}
descriptionId={quoteDescriptionId}
classPrefix='visibility-dropdown'
disabled={disableQuotePolicy}
id={quoteDropdownId}
/>
<QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
</label>
<QuotePolicyHelper
policy={quotePolicy}
visibility={visibility}
className='visibility-dropdown__helper'
id={quoteDescriptionId}
/>
</div>
</div>
<div className='dialog-modal__content__actions'>
<Button onClick={onClose} secondary>
@ -317,42 +337,44 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
);
VisibilityModal.displayName = 'VisibilityModal';
const QuotePolicyHelper: FC<{
policy: ApiQuotePolicy;
visibility: StatusVisibility;
}> = ({ policy, visibility }) => {
const QuotePolicyHelper: FC<
{
policy: ApiQuotePolicy;
visibility: StatusVisibility;
} & React.ComponentPropsWithoutRef<'p'>
> = ({ policy, visibility, ...otherProps }) => {
let hintText: React.ReactElement | undefined;
if (visibility === 'unlisted' && policy !== 'nobody') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.unlisted_quoting'
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.unlisted_quoting'
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
/>
);
}
if (visibility === 'private') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.private_quoting'
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.private_quoting'
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
/>
);
}
if (visibility === 'direct') {
return (
<p className='visibility-dropdown__helper'>
<FormattedMessage
id='visibility_modal.helper.direct_quoting'
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
/>
</p>
hintText = (
<FormattedMessage
id='visibility_modal.helper.direct_quoting'
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
/>
);
}
return null;
if (!hintText) {
return null;
}
return <p {...otherProps}>{hintText}</p>;
};

View File

@ -92,8 +92,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
layout: state.getIn(['meta', 'layout']),
isComposing: state.getIn(['compose', 'is_composing']),
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']),
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
newAccount: !state.getIn(['accounts', me, 'note']) && !state.getIn(['accounts', me, 'bot']) && state.getIn(['accounts', me, 'following_count'], 0) === 0 && state.getIn(['accounts', me, 'statuses_count'], 0) === 0,
@ -241,8 +240,7 @@ class UI extends PureComponent {
dispatch: PropTypes.func.isRequired,
children: PropTypes.node,
isComposing: PropTypes.bool,
hasComposingText: PropTypes.bool,
hasMediaAttachments: PropTypes.bool,
hasComposingContents: PropTypes.bool,
canUploadMore: PropTypes.bool,
intl: PropTypes.object.isRequired,
layout: PropTypes.string.isRequired,
@ -257,11 +255,11 @@ class UI extends PureComponent {
};
handleBeforeUnload = e => {
const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
const { intl, dispatch, isComposing, hasComposingContents } = this.props;
dispatch(synchronouslySubmitMarkers());
if (isComposing && (hasComposingText || hasMediaAttachments)) {
if (isComposing && hasComposingContents) {
e.preventDefault();
// Setting returnValue to any string causes confirmation dialog.
// Many browsers no longer display this text to users,

View File

@ -256,7 +256,6 @@
"status.edited_x_times": "Edited {count, plural, one {# time} other {# times}}",
"status.open": "Brei hierdie plasing uit",
"status.reblog": "Stuur aan",
"status.reblog_private": "Stuur aan met oorspronklike sigbaarheid",
"status.reblogged_by": "Aangestuur deur {name}",
"status.reblogs.empty": "Hierdie plasing is nog deur niemand aangestuur nie. As iemand dit aanstuur, sal jy dit hier sien.",
"status.reply": "Reageer",

View File

@ -442,7 +442,6 @@
"status.pin": "Fixar",
"status.read_more": "Leyer mas",
"status.reblog": "Retutar",
"status.reblog_private": "Empentar con l'audiencia orichinal",
"status.reblogged_by": "Retutau per {name}",
"status.reblogs.empty": "Dengún no ha retutau encara este tut. Quan belún lo faiga, amaneixerá aquí.",
"status.redraft": "Borrar y tornar ta borrador",

View File

@ -731,7 +731,6 @@
"privacy.public.long": "أي شخص على أو خارج ماستدون",
"privacy.public.short": "للعامة",
"privacy.unlisted.additional": "هذا يتصرف بالضبط مثل النشر للعامة، باستثناء أن المنشور لن يظهر في الموجزات الحية أو في الوسوم أو في الإستكشاف، أو في نتائج بحث ماستدون، حتى وإن قمت بتفعيله على مستوى الحساب.",
"privacy.unlisted.long": "خوارزميات أقل",
"privacy.unlisted.short": "للعامة دون صخب",
"privacy_policy.last_updated": "آخر تحديث {date}",
"privacy_policy.title": "سياسة الخصوصية",
@ -874,7 +873,6 @@
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
"status.read_more": "اقرأ المزيد",
"status.reblog": "إعادة النشر",
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
"status.reblogged_by": "شارَكَه {name}",
"status.reblogs": "{count, plural, one {إعادة نشر واحدة} two {معاد نشرها مرتان} few {# إعادات نشر} many {# إعادات نشر} other {# إعادة نشر}}",
"status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",

View File

@ -872,7 +872,6 @@
"status.quote_post_author": "@{name} göndərişini sitat gətirdi",
"status.read_more": "Daha çoxunu oxu",
"status.reblog": "Təkrar paylaş",
"status.reblog_private": "Orijinal görünmə ilə təkrar paylaş",
"status.reblogged_by": "{name} təkrar paylaşdı",
"status.reblogs": "{count, plural, one {təkrar paylaşma} other {təkrar paylaşma}}",
"status.reblogs.empty": "Hələ heç kim bu göndərişi təkrar paylaşmayıb. Kimsə paylaşdığı zaman, burada görünəcək.",
@ -946,7 +945,6 @@
"video.volume_up": "Həcmi artır",
"visibility_modal.button_title": "Görünməni ayarla",
"visibility_modal.header": "Görünmə və qarşılıqlı əlaqə",
"visibility_modal.helper.privacy_editing": "Dərc edilən göndərişlərin görünməsi dəyişdirilə bilməz.",
"visibility_modal.helper.unlisted_quoting": "İnsanlar sizdən sitat gətirdiyi zaman, onların göndərişləri də trend zaman xəttindən gizlədiləcək.",
"visibility_modal.quote_followers": "Yalnız izləyicilər",
"visibility_modal.quote_public": "Hər kəs"

View File

@ -239,6 +239,10 @@
"confirmations.missing_alt_text.secondary": "Усё адно апублікаваць",
"confirmations.missing_alt_text.title": "Дадаць альтэрнатыўны тэкст?",
"confirmations.mute.confirm": "Ігнараваць",
"confirmations.quiet_post_quote_info.dismiss": "Не нагадваць зноў",
"confirmations.quiet_post_quote_info.got_it": "Зразумела",
"confirmations.quiet_post_quote_info.message": "Калі будзеце цытаваць ціхі публічны допіс, Ваш допіс будзе схаваны ад трэндавых стужак.",
"confirmations.quiet_post_quote_info.title": "Цытаванне ціхіх публічных допісаў",
"confirmations.redraft.confirm": "Выдаліць і перапісаць",
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
@ -745,7 +749,7 @@
"privacy.quote.disabled": "{visibility}, цытаты адключаныя",
"privacy.quote.limited": "{visibility}, абмежаваныя цытаты",
"privacy.unlisted.additional": "Паводзіць сябе гэтак жа, як і публічны, за выключэннем таго, што допіс не будзе адлюстроўвацца ў жывой стужцы, хэштэгах, аглядзе або ў пошуку Mastodon, нават калі Вы ўключылі бачнасць у пошуку ў наладах.",
"privacy.unlisted.long": "Менш фанфар ад алгарытмаў",
"privacy.unlisted.long": "Схаваны ад вынікаў пошуку Mastodon, трэндавага і публічных стужак",
"privacy.unlisted.short": "Ціхі публічны",
"privacy_policy.last_updated": "Адноўлена {date}",
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
@ -768,6 +772,9 @@
"relative_time.minutes": "{number} хв",
"relative_time.seconds": "{number} с",
"relative_time.today": "сёння",
"remove_quote_hint.button_label": "Зразумела",
"remove_quote_hint.message": "Можаце зрабіце гэта ў {icon} меню налад.",
"remove_quote_hint.title": "Хочаце выдаліць Ваш допіс з цытатай?",
"reply_indicator.attachments": "{count, plural, one {# далучэнне} few {# далучэнні} many {# далучэнняў} other {# далучэння}}",
"reply_indicator.cancel": "Скасаваць",
"reply_indicator.poll": "Апытанне",
@ -910,15 +917,15 @@
"status.read_more": "Чытаць болей",
"status.reblog": "Пашырыць",
"status.reblog_or_quote": "Пашырыць ці цытаваць",
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
"status.reblogged_by": "Карыстальнік {name} пашырыў",
"status.reblog_private": "Зноў падзяліцца са сваімі падпісчыкамі",
"status.reblogged_by": "{name} пашырыў(-ла)",
"status.reblogs": "{count, plural, one {пашырэнне} few {пашырэнні} many {пашырэнняў} other {пашырэння}}",
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
"status.redraft": "Выдаліць і перапісаць",
"status.remove_bookmark": "Выдаліць закладку",
"status.remove_favourite": "Выдаліць з упадабаных",
"status.replied_in_thread": "Адказаў у ланцужку",
"status.replied_to": "Адказаў {name}",
"status.replied_in_thread": "Адказаў(-ла) у ланцужку",
"status.replied_to": "Адказаў(-ла) {name}",
"status.reply": "Адказаць",
"status.replyAll": "Адказаць у ланцугу",
"status.report": "Паскардзіцца на @{name}",
@ -988,7 +995,7 @@
"visibility_modal.button_title": "Вызначыць бачнасць",
"visibility_modal.header": "Бачнасць і ўзаемадзеянне",
"visibility_modal.helper.direct_quoting": "Прыватныя згадванні, створаныя на Mastodon, нельга цытаваць іншым людзям.",
"visibility_modal.helper.privacy_editing": "Апублікаваным допісам нельга змяняць бачнасць.",
"visibility_modal.helper.privacy_editing": "Бачнасць нельга змяніць у апублікаваным допісе.",
"visibility_modal.helper.privacy_private_self_quote": "Самацытаванне прыватных допісаў не можа быць публічным.",
"visibility_modal.helper.private_quoting": "Допісы для падпісчыкаў, створаныя на Mastodon, нельга цытаваць іншым людзям.",
"visibility_modal.helper.unlisted_quoting": "Калі людзі працытуюць Вас, іх допіс таксама будзе схаваны ад стужкі трэндаў.",

View File

@ -729,7 +729,6 @@
"privacy.public.long": "Всеки във и извън Mastodon",
"privacy.public.short": "Публично",
"privacy.unlisted.additional": "Това действие е точно като публичното, с изключение на това, че публикацията няма да се появява в каналите на живо, хаштаговете, разглеждането или търсенето в Mastodon, дори ако сте избрали да се публично видими на ниво акаунт.",
"privacy.unlisted.long": "По-малко алгоритмични фанфари",
"privacy.unlisted.short": "Тиха публика",
"privacy_policy.last_updated": "Последно осъвременяване на {date}",
"privacy_policy.title": "Политика за поверителност",
@ -877,7 +876,6 @@
"status.quote_post_author": "Цитирах публикация от @{name}",
"status.read_more": "Още за четене",
"status.reblog": "Подсилване",
"status.reblog_private": "Подсилване с оригиналната видимост",
"status.reblogged_by": "{name} подсили",
"status.reblogs": "{count, plural, one {подсилване} other {подсилвания}}",
"status.reblogs.empty": "Още никого не е подсилвал публикацията. Подсилващият ще се покаже тук.",
@ -952,7 +950,6 @@
"video.volume_up": "Увеличаване на звука",
"visibility_modal.button_title": "Задаване на видимост",
"visibility_modal.header": "Видимост и взаимодействие",
"visibility_modal.helper.privacy_editing": "Публикуваните публикации не може да променят видимостта си.",
"visibility_modal.quote_followers": "Само последователи",
"visibility_modal.quote_public": "Някой"
}

View File

@ -377,7 +377,6 @@
"status.pin": "নিজের পাতায় এটা পিন করতে",
"status.read_more": "আরো পড়ুন",
"status.reblog": "সমর্থন দিতে",
"status.reblog_private": "আপনার অনুসরণকারীদের কাছে এটার সমর্থন দেখাতে",
"status.reblogged_by": "{name} সমর্থন দিয়েছে",
"status.reblogs.empty": "এখনো কেও এটাতে সমর্থন দেয়নি। যখন কেও দেয়, সেটা তখন এখানে দেখা যাবে।",
"status.redraft": "মুছে আবার নতুন করে লিখতে",

View File

@ -7,12 +7,13 @@
"about.domain_blocks.preamble": "Gant Mastodon e c'hellit gwelet danvez hag eskemm gant implijerien·ezed eus forzh peseurt servijer er fedibed peurliesañ. Setu an nemedennoù a zo bet graet evit ar servijer-mañ e-unan.",
"about.domain_blocks.silenced.explanation": "Ne vo ket gwelet profiloù eus ar servijer-mañ ganeoc'h peurliesañ, nemet ma vefec'h o klask war o lec'h pe choazfec'h o heuliañ.",
"about.domain_blocks.silenced.title": "Bevennet",
"about.domain_blocks.suspended.explanation": "Roadenn ebet eus ar servijer-mañ ne vo keweriet, kadavet pe eskemmet, ar pezh a lako an etreweriañ pe ar c'hehentiñ gant implijerien adalek ar servijer-mañ dibosupl.",
"about.domain_blocks.suspended.explanation": "Roadenn ebet eus ar servijer-mañ ne vo keweriet, kadavet pe eskemmet, ar pezh a lako an etreweriañ pe ar c'hehentiñ gant implijerien·ezed adalek ar servijer-mañ dibosupl.",
"about.domain_blocks.suspended.title": "Astalet",
"about.language_label": "Yezh",
"about.not_available": "An titour-mañ ne c'heller ket gwelet war ar servijer-mañ.",
"about.powered_by": "Rouedad sokial digreizenned kaset gant {mastodon}",
"about.rules": "Reolennoù ar servijer",
"account.account_note_header": "Notenn bersonel",
"account.add_or_remove_from_list": "Ouzhpenn pe dilemel eus al listennadoù",
"account.badges.bot": "Robot",
"account.badges.group": "Strollad",
@ -20,17 +21,22 @@
"account.block_domain": "Stankañ an domani {domain}",
"account.block_short": "Stankañ",
"account.blocked": "Stanket",
"account.blocking": "Stanket",
"account.cancel_follow_request": "Nullañ ar reked heuliañ",
"account.copy": "Eilañ al liamm war-zu ho profil",
"account.direct": "Menegiñ @{name} ent-prevez",
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
"account.domain_blocking": "Domani stanket",
"account.edit_profile": "Kemmañ ar profil",
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
"account.endorse": "Lakaat war-wel war ar profil",
"account.familiar_followers_one": "Heuilhet gant {name1}",
"account.familiar_followers_two": "Heuilhet gant {name1} ha {name2}",
"account.featured_tags.last_status_at": "Toud diwezhañ : {date}",
"account.featured_tags.last_status_never": "Embannadur ebet",
"account.endorse": "Lakaat en a-raok war ar profil",
"account.familiar_followers_one": "Heuliet gant {name1}",
"account.familiar_followers_two": "Heuliet gant {name1} ha {name2}",
"account.featured": "En a-raok",
"account.featured.accounts": "Profiloù",
"account.featured.hashtags": "Gerioù-klik",
"account.featured_tags.last_status_at": "Embann diwezhañ: {date}",
"account.featured_tags.last_status_never": "Embann ebet",
"account.follow": "Heuliañ",
"account.follow_back": "Heuliañ d'ho tro",
"account.followers": "Tud koumanantet",
@ -39,6 +45,7 @@
"account.followers_you_know_counter": "{counter} a anavezit",
"account.following": "Koumanantoù",
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
"account.follows_you": "Ho heuilh",
"account.go_to_profile": "Gwelet ar profil",
"account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
"account.in_memoriam": "E koun.",
@ -53,20 +60,25 @@
"account.mute_notifications_short": "Kuzhat ar c'hemennoù",
"account.mute_short": "Kuzhat",
"account.muted": "Kuzhet",
"account.muting": "O kuzhat",
"account.mutual": "Heuliañ a rit an eil egile",
"account.no_bio": "Deskrivadur ebet da gaout.",
"account.open_original_page": "Digeriñ ar bajenn orin",
"account.posts": "Embannadurioù",
"account.posts_with_replies": "Embannadurioù ha respontoù",
"account.remove_from_followers": "Dilemel {name} eus an heulierien·ezed",
"account.report": "Disklêriañ @{name}",
"account.requested": "O c'hortoz an asant. Klikit evit nullañ ar goulenn heuliañ",
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
"account.requests_to_follow_you": "Rekedoù d'ho heuliañ",
"account.share": "Skignañ profil @{name}",
"account.show_reblogs": "Diskouez skignadennoù @{name}",
"account.statuses_counter": "{count, plural, one {{counter} embannadur} two {{counter} embannadur} few {{counter} embannadur} many {{counter} embannadur} other {{counter} embannadur}}",
"account.unblock": "Diverzañ @{name}",
"account.unblock_domain": "Diverzañ an domani {domain}",
"account.unblock": "Distankañ @{name}",
"account.unblock_domain": "Distankañ an domani {domain}",
"account.unblock_domain_short": "Distankañ",
"account.unblock_short": "Distankañ",
"account.unendorse": "Paouez da lakaat war-wel war ar profil",
"account.unendorse": "Na lakaat ket en a-raok war ar profil",
"account.unfollow": "Diheuliañ",
"account.unmute": "Diguzhat @{name}",
"account.unmute_notifications_short": "Diguzhat ar c'hemennoù",
@ -76,27 +88,39 @@
"admin.dashboard.monthly_retention": "Feur azdalc'h an implijerien·ezed dre viz goude bezañ lakaet o anv",
"admin.dashboard.retention.average": "Keidenn",
"admin.dashboard.retention.cohort": "Miz an enrolladur",
"admin.dashboard.retention.cohort_size": "Implijerien.erezed nevez",
"admin.dashboard.retention.cohort_size": "Implijerien·ezed nevez",
"admin.impact_report.instance_accounts": "Profiloù kontoù a vefe dilamet",
"admin.impact_report.instance_followers": "Heulierien a gollfe hon implijerien",
"admin.impact_report.instance_follows": "Heulierien a gollfe o implijerien",
"admin.impact_report.instance_followers": "Heulierien a gollfe hon implijerien·ezed",
"admin.impact_report.instance_follows": "Heulierien a gollfe o implijerien·ezed",
"alert.rate_limited.message": "Klaskit en-dro a-benn {retry_time, time, medium}.",
"alert.rate_limited.title": "Feur bevennet",
"alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
"alert.unexpected.title": "Hopala !",
"alt_text_badge.title": "Eiltestenn",
"alt_text_modal.add_alt_text": "Ouzhpennañ un eiltestenn",
"alt_text_modal.cancel": "Nullañ",
"alt_text_modal.change_thumbnail": "Kemmañ ar velvenn",
"alt_text_modal.done": "Graet",
"announcement.announcement": "Kemennad",
"annual_report.summary.followers.followers": "heulier",
"annual_report.summary.followers.total": "{count} en holl",
"annual_report.summary.highlighted_post.by_favourites": "embannadur karet ar muiañ",
"annual_report.summary.highlighted_post.by_reblogs": "embannadur skignet ar muiañ",
"annual_report.summary.highlighted_post.by_replies": "embannadur gant ar muiañ a respontoù",
"annual_report.summary.highlighted_post.possessive": "{name}",
"annual_report.summary.most_used_app.most_used_app": "arload muiañ implijet",
"annual_report.summary.most_used_hashtag.most_used_hashtag": "ar gerioù-klik implijet ar muiañ",
"annual_report.summary.most_used_hashtag.none": "Hini ebet",
"annual_report.summary.new_posts.new_posts": "toudoù nevez",
"annual_report.summary.new_posts.new_posts": "embannadurioù nevez",
"attachments_list.unprocessed": "(ket meret)",
"audio.hide": "Kuzhat ar c'hleved",
"block_modal.show_less": "Diskouez nebeutoc'h",
"block_modal.show_more": "Diskouez muioc'h",
"block_modal.they_cant_mention": "Ne c'hall na menegiñ na heuliañ ac'hanoc'h.",
"block_modal.title": "Stankañ an implijer·ez?",
"boost_modal.combo": "Ar wezh kentañ e c'halliot gwaskañ war {combo} evit tremen hebiou",
"boost_modal.reblog": "Skignañ an embannadur?",
"boost_modal.undo_reblog": "Paouez da skignañ an embannadur?",
"bundle_column_error.copy_stacktrace": "Eilañ an danevell fazi",
"bundle_column_error.error.body": "N'haller ket skrammañ ar bajenn goulennet. Gallout a ra bezañ abalamour d'ur beug er c'hod pe d'ur gudenn keverlec'hded gant ar merdeer.",
"bundle_column_error.error.title": "Chaous !",
@ -114,20 +138,20 @@
"closed_registrations_modal.preamble": "Digreizennet eo Mastodon, forzh pelec'h e vefe krouet ho kont e viot gouest da heuliañ hag etreweriañ gant an holl war ar servijer-mañ. Gallout a rit herbec'hiañ anezhañ hoc'h-unan zoken!",
"closed_registrations_modal.title": "Enskrivadurioù war Mastodon",
"column.about": "Diwar-benn",
"column.blocks": "Implijer·ezed·ien berzet",
"column.blocks": "Implijerien·ezed stanket",
"column.bookmarks": "Sinedoù",
"column.community": "Red-amzer lec'hel",
"column.create_list": "Krouiñ ul listenn",
"column.direct": "Menegoù prevez",
"column.directory": "Mont a-dreuz ar profiloù",
"column.domain_blocks": "Domani berzet",
"column.domain_blocks": "Domani stanket",
"column.edit_list": "Kemmañ al listenn",
"column.favourites": "Muiañ-karet",
"column.firehose": "Redoù war-eeun",
"column.follow_requests": "Rekedoù heuliañ",
"column.home": "Degemer",
"column.lists": "Listennoù",
"column.mutes": "Implijer·ion·ezed kuzhet",
"column.mutes": "Implijerien·ezed kuzhet",
"column.notifications": "Kemennoù",
"column.pins": "Embannadurioù spilhennet",
"column.public": "Red-amzer kevredet",
@ -148,9 +172,9 @@
"compose.published.open": "Digeriñ",
"compose.saved.body": "Enrollet.",
"compose_form.direct_message_warning_learn_more": "Gouzout hiroc'h",
"compose_form.encryption_warning": "Toudoù war Mastodon na vezont ket sifret penn-da-benn. Na rannit ket titouroù kizidik dre Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.lock_disclaimer": "N'eo ket {locked} ho kont. An holl a c'hal ho heuliañ evit gwelet ho toudoù prevez.",
"compose_form.encryption_warning": "Embannadurioù war Mastodon na vezont ket sifret a-benn-da-benn. Na rannit ket titouroù kizidik dre Mastodon.",
"compose_form.hashtag_warning": "Ne vo ket listennet an embannadur-mañ dindan gerioù-klik ebet dre m'eo anlistennet. N'eus nemet an embannadurioù foran a c'hall bezañ klasket dre c'her-klik.",
"compose_form.lock_disclaimer": "N'eo ket {locked} ho kont. An holl a c'hal ho heuliañ evit gwelet hoc'h embannadurioù prevez.",
"compose_form.lock_disclaimer.lock": "prennet",
"compose_form.placeholder": "Petra emaoc'h o soñjal e-barzh ?",
"compose_form.poll.duration": "Pad ar sontadeg",
@ -167,23 +191,41 @@
"confirmation_modal.cancel": "Nullañ",
"confirmations.block.confirm": "Stankañ",
"confirmations.delete.confirm": "Dilemel",
"confirmations.delete.message": "Ha sur oc'h e fell deoc'h dilemel an toud-mañ ?",
"confirmations.delete.message": "Ha sur oc'h e fell deoc'h dilemel an embannadur-mañ?",
"confirmations.delete.title": "Dilemel an embannadur?",
"confirmations.delete_list.confirm": "Dilemel",
"confirmations.delete_list.message": "Ha sur eo hoc'h eus c'hoant da zilemel ar roll-mañ da vat ?",
"confirmations.delete_list.title": "Dilemel al listenn?",
"confirmations.discard_draft.confirm": "Diverkañ ha kenderc'hel",
"confirmations.discard_draft.edit.cancel": "Distreiñ d'an embann",
"confirmations.discard_draft.post.cancel": "Distreiñ d'ar brouilhed",
"confirmations.discard_draft.post.title": "Dilemel ho prouilhed?",
"confirmations.discard_edit_media.confirm": "Nac'hañ",
"confirmations.discard_edit_media.message": "Bez ez eus kemmoù n'int ket enrollet e deskrivadur ar media pe ar rakwel, nullañ anezho evelato?",
"confirmations.follow_to_list.confirm": "Heuliañ hag ouzhpennañ d'al listenn",
"confirmations.follow_to_list.title": "Heuliañ an implijer·ez?",
"confirmations.logout.confirm": "Digevreañ",
"confirmations.logout.message": "Ha sur oc'h e fell deoc'h digevreañ ?",
"confirmations.logout.title": "Digevreañ?",
"confirmations.missing_alt_text.confirm": "Ouzhpennañ un eiltestenn",
"confirmations.missing_alt_text.secondary": "Embann memes tra",
"confirmations.missing_alt_text.title": "Ouzhpennañ an eiltestenn?",
"confirmations.mute.confirm": "Kuzhat",
"confirmations.redraft.confirm": "Diverkañ ha skrivañ en-dro",
"confirmations.redraft.title": "Diverkañ ha skrivañ an embann en-dro?",
"confirmations.remove_from_followers.confirm": "Dilemel an heulier·ez",
"confirmations.remove_from_followers.title": "Dilemel an heulier·ez?",
"confirmations.revoke_quote.confirm": "Dilemel an embannadur",
"confirmations.revoke_quote.title": "Dilemel an embannadur?",
"confirmations.unfollow.confirm": "Diheuliañ",
"confirmations.unfollow.message": "Ha sur oc'h e fell deoc'h paouez da heuliañ {name} ?",
"confirmations.unfollow.title": "Paouez da heuliañ an implijer·ez?",
"content_warning.hide": "Kuzhat an embannadur",
"content_warning.show": "Diskwel memes tra",
"content_warning.show_more": "Diskouez muioc'h",
"conversation.delete": "Dilemel ar gaozeadenn",
"conversation.mark_as_read": "Merkañ evel lennet",
"conversation.open": "Gwelout ar gaozeadenn",
"conversation.open": "Gwelet ar gaozeadenn",
"conversation.with": "Gant {names}",
"copy_icon_button.copied": "Eilet er golver",
"copypaste.copied": "Eilet",
@ -194,11 +236,17 @@
"directory.recently_active": "Oberiant nevez zo",
"disabled_account_banner.account_settings": "Arventennoù ar gont",
"disabled_account_banner.text": "Ho kont {disabledAccount} zo divev evit bremañ.",
"dismissable_banner.community_timeline": "Setu toudoù foran nevesañ an dud a zo herberchiet o c'hontoù gant {domain}.",
"dismissable_banner.community_timeline": "Setu embannadurioù foran nevesañ an dud a zo herberchiet o chontoù gant {domain}.",
"dismissable_banner.dismiss": "Diverkañ",
"domain_block_modal.block": "Stankañ ar servijer",
"domain_block_modal.block_account_instead": "Stankañ @{name} kentoc'h",
"domain_block_modal.title": "Stankañ an domani?",
"domain_pill.server": "Dafariad",
"domain_pill.username": "Anv-implijer",
"embed.instructions": "Enframmit an toud-mañ en ho lec'hienn en ur eilañ ar c'hod amañ-dindan.",
"domain_pill.whats_in_a_handle": "Petra eo an anaouder?",
"domain_pill.your_handle": "Hoc'h anaouder:",
"dropdown.empty": "Diuzit un dibarzh",
"embed.instructions": "Enframmit an embannadur-mañ en ho lechienn en ur eilañ ar chod amañ-dindan.",
"embed.preview": "Setu penaos e teuio war wel :",
"emoji_button.activity": "Obererezh",
"emoji_button.clear": "Diverkañ",
@ -215,22 +263,22 @@
"emoji_button.search_results": "Disoc'hoù an enklask",
"emoji_button.symbols": "Arouezioù",
"emoji_button.travel": "Beajiñ & Lec'hioù",
"empty_column.account_suspended": "Kont ehanet",
"empty_column.account_timeline": "Toud ebet amañ !",
"empty_column.account_suspended": "Kont astalet",
"empty_column.account_timeline": "Embannadur ebet amañ!",
"empty_column.account_unavailable": "Profil dihegerz",
"empty_column.blocks": "N'eus ket bet berzet implijer·ez ganeoc'h c'hoazh.",
"empty_column.blocks": "N'eus ket bet stanket implijer·ez ganeoc'h c'hoazh.",
"empty_column.bookmarked_statuses": "N'ho peus embannadur ebet enrollet en ho sinedoù c'hoazh. Pa vo ouzhpennet unan e teuio war wel amañ.",
"empty_column.community": "Goulo eo ar red-amzer lec'hel. Skrivit'ta un dra evit lakaat tan dezhi !",
"empty_column.domain_blocks": "N'eus domani kuzh ebet c'hoazh.",
"empty_column.domain_blocks": "N'eus domani stanket ebet c'hoazh.",
"empty_column.explore_statuses": "N'eus tuadur ebet evit c'hoazh. Distroit diwezhatoc'h !",
"empty_column.follow_requests": "N'ho peus reked heuliañ ebet c'hoazh. Pa vo resevet unan e teuio war wel amañ.",
"empty_column.followed_tags": "N'emaoc'h oc'h heuliañ hashtag ebet evit poent. Pa vioc'h e vo d'o gwelet amañ.",
"empty_column.hashtag": "N'eus netra en hashtag-mañ c'hoazh.",
"empty_column.followed_tags": "N'emaoc'h oc'h heuliañ ger-klik ebet evit poent. Pa vioc'h e vo d'o gwelet amañ.",
"empty_column.hashtag": "N'eus netra er ger-klik-mañ c'hoazh.",
"empty_column.home": "Goullo eo ho red-amzer degemer! Kit da weladenniñ {public} pe implijit ar c'hlask evit kregiñ ganti ha kejañ gant implijer·ien·ezed all.",
"empty_column.list": "Goullo eo al listenn-mañ evit c'hoazh. Pa vo embannet toudoù nevez gant e izili e teuint war wel amañ.",
"empty_column.mutes": "N'ho peus kuzhet implijer ebet c'hoazh.",
"empty_column.list": "Goullo eo al listenn-mañ evit c'hoazh. Pa vo embannet un dra nevez gant e izili e teuio war wel amañ.",
"empty_column.mutes": "N'ho peus kuzhet implijer·ez ebet c'hoazh.",
"empty_column.notifications": "N'ho peus kemenn ebet c'hoazh. Grit gant implijer·ezed·ien all evit loc'hañ ar gomz.",
"empty_column.public": "N'eus netra amañ! Skrivit un dra bennak foran pe heuilhit implijer·ien·ezed eus dafariadoù all evit leuniañ",
"empty_column.public": "N'eus netra amañ! Skrivit un dra bennak foran pe heuilhit implijerien·ezed eus servijerioù all evit leuniañ",
"error.unexpected_crash.explanation": "Abalamour d'ur beug en hor c'hod pe d'ur gudenn geverlec'hded n'hallomp ket skrammañ ar bajenn-mañ en un doare dereat.",
"error.unexpected_crash.explanation_addons": "Ar bajenn-mañ ne c'hell ket bezañ skrammet mat. Ar fazi-se a zo kaoz d'un astenn pe d'un ostilh troidigezh emgefreek war ho merdeer.",
"error.unexpected_crash.next_steps": "Klaskit azbevaat ar bajenn. Ma n'a ket en-dro e c'hallit klask ober gant Mastodon dre ur merdeer disheñvel pe dre an arload genidik.",
@ -241,24 +289,24 @@
"explore.title": "Diouzh ar c'hiz",
"explore.trending_links": "Keleier",
"explore.trending_statuses": "Embannadurioù",
"explore.trending_tags": "Hashtagoù",
"explore.trending_tags": "Gerioù-klik",
"featured_carousel.next": "War-raok",
"featured_carousel.post": "Embannadenn",
"featured_carousel.post": "Embannadur",
"featured_carousel.previous": "War-gil",
"featured_carousel.slide": "{index} diwar {total}",
"filter_modal.added.context_mismatch_title": "Kenarroud digenglotus !",
"filter_modal.added.expired_title": "Sil deuet d'e dermen !",
"filter_modal.added.review_and_configure_title": "Arventennoù ar sil",
"filter_modal.added.settings_link": "pajenn an arventennoù",
"filter_modal.added.short_explanation": "An toud-mañ zo bet ouzhpennet d'ar rummad sil-mañ : {title}.",
"filter_modal.added.short_explanation": "Ouzhpennet eo bet an embannadur-mañ d'ar rummad sil-mañ: {title}.",
"filter_modal.added.title": "Sil ouzhpennet !",
"filter_modal.select_filter.context_mismatch": "na glot ket gant ar c'henarroud-mañ",
"filter_modal.select_filter.expired": "zo deuet d'e dermen",
"filter_modal.select_filter.prompt_new": "Rummad nevez : {name}",
"filter_modal.select_filter.search": "Klask pe krouiñ",
"filter_modal.select_filter.subtitle": "Implijout ur rummad a zo anezhañ pe krouiñ unan nevez",
"filter_modal.select_filter.title": "Silañ an toud-mañ",
"filter_modal.title.status": "Silañ un toud",
"filter_modal.select_filter.title": "Silañ an embannadur-mañ",
"filter_modal.title.status": "Silañ un embannadur",
"filter_warning.matches_filter": "A glot gant ar sil “<span>{title}</span>”",
"firehose.all": "Pep tra",
"firehose.local": "Ar servijer-mañ",
@ -266,10 +314,12 @@
"follow_request.authorize": "Aotren",
"follow_request.reject": "Nac'hañ",
"follow_requests.unlocked_explanation": "Daoust ma n'eo ket ho kont prennet, skipailh {domain} a soñj e fellfe deoc'h gwiriekaat pedadennoù heuliañ deus ar c'hontoù-se diwar-zorn.",
"follow_suggestions.dismiss": "Na ziskouez ket ken",
"follow_suggestions.friends_of_friends_longer": "Diouzh ar c'hiz e-touez an dud heuliet ganeoc'h",
"follow_suggestions.popular_suggestion_longer": "Diouzh ar c'hiz war {domain}",
"follow_suggestions.view_all": "Gwelet pep tra",
"followed_tags": "Hashtagoù o heuliañ",
"follow_suggestions.who_to_follow": "Piv heuliañ",
"followed_tags": "Gerioù-klik o heuliañ",
"footer.about": "Diwar-benn",
"footer.directory": "Kavlec'h ar profiloù",
"footer.get_app": "Pellgargañ an arload",
@ -281,20 +331,26 @@
"generic.saved": "Enrollet",
"getting_started.heading": "Loc'hañ",
"hashtag.admin_moderation": "Digeriñ an etrefas evezhiañ evit #{name}",
"hashtag.browse": "Furchal dre an embannadurioù gant #{hashtag}",
"hashtag.browse_from_account": "Furchal dre an embannadurioù gant @{name} gant #{hashtag}",
"hashtag.column_header.tag_mode.all": "ha(g) {additional}",
"hashtag.column_header.tag_mode.any": "pe {additional}",
"hashtag.column_header.tag_mode.none": "hep {additional}",
"hashtag.column_settings.select.no_options_message": "N'eus bet kavet ali ebet",
"hashtag.column_settings.select.placeholder": "Ouzhpennañ hashtagoù…",
"hashtag.column_settings.select.placeholder": "Ouzhpennañ geri-klik…",
"hashtag.column_settings.tag_mode.all": "An holl anezho",
"hashtag.column_settings.tag_mode.any": "Unan e mesk anezho",
"hashtag.column_settings.tag_mode.none": "Hini ebet anezho",
"hashtag.column_settings.tag_toggle": "Endelc'her gerioù-alc'hwez ouzhpenn evit ar bannad-mañ",
"hashtag.counter_by_uses": "{count, plural, one {{counter} embannadur} other {{counter} embannadur}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} embannadur} other {{counter} embannadur}} hiziv",
"hashtag.feature": "Lakaat en a-raok war ar profil",
"hashtag.follow": "Heuliañ ar ger-klik",
"hashtag.unfollow": "Paouez heuliañ an hashtag",
"hashtag.mute": "Kuzhat #{hashtag}",
"hashtag.unfeature": "Na lakaat ket en a-raok war ar profil",
"hashtag.unfollow": "Diheuliañ ar ger-klik",
"hashtags.and_other": "…{count, plural, one {hag # all} other {ha # all}}",
"hints.profiles.see_more_posts": "Gwelet embannadurioù ouzhpenn eus {domain}",
"home.column_settings.show_quotes": "Diskouez an arroudennoù",
"home.column_settings.show_reblogs": "Diskouez ar skignadennoù",
"home.column_settings.show_replies": "Diskouez ar respontoù",
@ -302,24 +358,27 @@
"home.pending_critical_update.body": "Hizivait ho servijer Mastodon kerkent ha ma c'hallit mar plij!",
"home.pending_critical_update.link": "Gwelet an hizivadennoù",
"home.show_announcements": "Diskouez ar c'hemennoù",
"interaction_modal.go": "Mont di",
"interaction_modal.no_account_yet": "N'ho peus ket ur gont c'hoazh?",
"interaction_modal.on_another_server": "War ur servijer all",
"interaction_modal.on_this_server": "War ar servijer-mañ",
"interaction_modal.title.favourite": "Ouzhpennañ embannadur {name} d'ar re vuiañ-karet",
"interaction_modal.title.follow": "Heuliañ {name}",
"interaction_modal.title.reblog": "Skignañ toud {name}",
"interaction_modal.title.reblog": "Skignañ embannadur {name}",
"interaction_modal.title.reply": "Respont da doud {name}",
"interaction_modal.username_prompt": "D.s. {example}",
"intervals.full.days": "{number, plural, one {# devezh} other{# a zevezhioù}}",
"intervals.full.hours": "{number, plural, one {# eurvezh} other{# eurvezh}}",
"intervals.full.minutes": "{number, plural, one {# munut} other{# a vunutoù}}",
"keyboard_shortcuts.back": "Distreiñ",
"keyboard_shortcuts.blocked": "Digeriñ roll an implijer.ezed.rien stanket",
"keyboard_shortcuts.boost": "Skignañ an toud",
"keyboard_shortcuts.blocked": "Digeriñ roll an implijerien·ezed stanket",
"keyboard_shortcuts.boost": "Skignañ an embannadur",
"keyboard_shortcuts.column": "Fokus ar bann",
"keyboard_shortcuts.compose": "Fokus an takad testenn",
"keyboard_shortcuts.description": "Deskrivadur",
"keyboard_shortcuts.direct": "to open direct messages column",
"keyboard_shortcuts.down": "Diskennañ er roll",
"keyboard_shortcuts.enter": "Digeriñ an toud",
"keyboard_shortcuts.enter": "Digeriñ an embannadur",
"keyboard_shortcuts.favourite": "Ouzhpennañ an embannadur d'ar re vuiañ-karet",
"keyboard_shortcuts.favourites": "Digeriñ roll an embannadurioù muiañ-karet",
"keyboard_shortcuts.federated": "Digeriñ ar red-amzer kevredet",
@ -329,74 +388,100 @@
"keyboard_shortcuts.legend": "Skrammañ ar skrid-se",
"keyboard_shortcuts.local": "Digeriñ red-amzer lec'hel",
"keyboard_shortcuts.mention": "Menegiñ an aozer.ez",
"keyboard_shortcuts.muted": "Digeriñ roll an implijer.ezed.ien kuzhet",
"keyboard_shortcuts.muted": "Digeriñ roll an implijerien·ezed kuzhet",
"keyboard_shortcuts.my_profile": "Digeriñ ho profil",
"keyboard_shortcuts.notifications": "Digeriñ bann ar c'hemennoù",
"keyboard_shortcuts.open_media": "Digeriñ ar media",
"keyboard_shortcuts.pinned": "Digeriñ listenn an toudoù spilhennet",
"keyboard_shortcuts.pinned": "Digeriñ listenn an embannadurioù spilhennet",
"keyboard_shortcuts.profile": "Digeriñ profil an aozer.ez",
"keyboard_shortcuts.reply": "Respont d'an toud",
"keyboard_shortcuts.quote": "Menegiñ an embannadur",
"keyboard_shortcuts.reply": "Respont d'an embannadur",
"keyboard_shortcuts.requests": "Digeriñ roll goulennoù heuliañ",
"keyboard_shortcuts.search": "Fokus barenn klask",
"keyboard_shortcuts.spoilers": "da guzhat/ziguzhat tachenn CW",
"keyboard_shortcuts.start": "Digeriñ bann \"Kregiñ\"",
"keyboard_shortcuts.toggle_hidden": "da guzhat/ziguzhat an desten a-dreñv CW",
"keyboard_shortcuts.toggle_sensitivity": "da guzhat/ziguzhat ur media",
"keyboard_shortcuts.toot": "Kregiñ gant un toud nevez",
"keyboard_shortcuts.toot": "Kregiñ gant un embannadur nevez",
"keyboard_shortcuts.translate": "da dreiñ un embannadur",
"keyboard_shortcuts.unfocus": "Difokus an dachenn testenn/klask",
"keyboard_shortcuts.up": "Pignat er roll",
"learn_more_link.got_it": "Mat eo",
"lightbox.close": "Serriñ",
"lightbox.next": "Da-heul",
"lightbox.previous": "A-raok",
"limited_account_hint.action": "Diskouez an aelad memes tra",
"limited_account_hint.action": "Diskouez ar profil memes tra",
"limited_account_hint.title": "Kuzhet eo bet ar profil-mañ gant an evezhierien eus {domain}.",
"link_preview.author": "Gant {name}",
"link_preview.more_from_author": "Muioc'h gant {name}",
"link_preview.shares": "{count, plural, one {{counter} embannadur} two {{counter} embannadur} few {{counter} embannadur} many {{counter} embannadur} other {{counter} embannadur}}",
"lists.add_member": "Ouzhpennañ",
"lists.add_to_list": "Ouzhpennañ d'al listenn",
"lists.create": "Krouiñ",
"lists.create_a_list_to_organize": "Krouit ul listenn evit renkañ ho red degemer",
"lists.create_list": "Krouiñ ul listenn",
"lists.delete": "Dilemel al listenn",
"lists.done": "Graet",
"lists.edit": "Kemmañ al listenn",
"lists.list_name": "Anv al listenn",
"lists.replies_policy.followed": "Pep implijer.ez heuliet",
"lists.new_list_name": "Anv nevez al listenn",
"lists.no_lists_yet": "Listenn ebet c'hoazh.",
"lists.remove_member": "Dilemel",
"lists.replies_policy.followed": "Pep implijer·ez heuliet",
"lists.replies_policy.list": "Izili ar roll",
"lists.replies_policy.none": "Den ebet",
"lists.save": "Enrollañ",
"lists.search": "Klask",
"load_pending": "{count, plural, one {# dra nevez} other {# dra nevez}}",
"loading_indicator.label": "O kargañ…",
"media_gallery.hide": "Kuzhat",
"mute_modal.hide_options": "Dibarzhioù ar c'huzhat",
"mute_modal.show_options": "Dibarzhioù an diskwel",
"mute_modal.title": "Kuzhat an implijer·ez?",
"navigation_bar.about": "Diwar-benn",
"navigation_bar.account_settings": "Ger-tremen ha surentez",
"navigation_bar.administration": "Merañ",
"navigation_bar.automated_deletion": "Dilemel an embannadenn ent-emgefreek",
"navigation_bar.blocks": "Implijer·ezed·ien berzet",
"navigation_bar.blocks": "Implijerien·ezed stanket",
"navigation_bar.bookmarks": "Sinedoù",
"navigation_bar.direct": "Menegoù prevez",
"navigation_bar.domain_blocks": "Domanioù kuzhet",
"navigation_bar.domain_blocks": "Domanioù stanket",
"navigation_bar.favourites": "Muiañ-karet",
"navigation_bar.filters": "Gerioù kuzhet",
"navigation_bar.follow_requests": "Pedadoù heuliañ",
"navigation_bar.followed_tags": "Hashtagoù o heuliañ",
"navigation_bar.follow_requests": "Rekedoù heuliañ",
"navigation_bar.followed_tags": "Gerioù-klik o heuliañ",
"navigation_bar.follows_and_followers": "Heuliadennoù ha heulier·ezed·ien",
"navigation_bar.import_export": "Enporzhiañ hag ezporzhiañ",
"navigation_bar.lists": "Listennoù",
"navigation_bar.logout": "Digennaskañ",
"navigation_bar.live_feed_local": "Red war-eeun (lec'hel)",
"navigation_bar.live_feed_public": "Red war-eeun (foran)",
"navigation_bar.logout": "Digevreañ",
"navigation_bar.moderation": "Habaskadur",
"navigation_bar.more": "Muioc'h",
"navigation_bar.mutes": "Implijer·ion·ezed kuzhet",
"navigation_bar.mutes": "Implijerien·ezed kuzhet",
"navigation_bar.preferences": "Gwellvezioù",
"navigation_bar.search": "Klask",
"navigation_bar.search_trends": "Klask / Diouzh ar c'hiz",
"not_signed_in_indicator.not_signed_in": "Ret eo deoc'h kevreañ evit tizhout an danvez-se.",
"notification.admin.report": "Disklêriet eo bet {target} gant {name}",
"notification.admin.sign_up": "{name} en·he deus lakaet e·hec'h anv",
"notification.favourite": "Gant {name} eo bet ouzhpennet hoc'h embannadur d'h·e re vuiañ-karet",
"notification.follow": "heuliañ a ra {name} ac'hanoc'h",
"notification.follow.name_and_others": "{name} <a>{count, plural, one {hag # den all} two {ha # zen all} few {ha # den all} many {ha # den all} other {ha # den all}}</a> zo o heuliañ ac'hanoc'h",
"notification.follow_request": "Gant {name} eo bet goulennet ho heuliañ",
"notification.label.quote": "Gant {name} eo bet meneget hoc'h embannadur",
"notification.label.reply": "Respont",
"notification.mention": "Meneg",
"notification.mentioned_you": "Gant {name} oc'h bet meneget",
"notification.moderation-warning.learn_more": "Gouzout hiroc'h",
"notification.moderation_warning.action_disable": "Diweredekaet eo bet ho kont.",
"notification.moderation_warning.action_suspend": "Astalet eo bet ho kont.",
"notification.own_poll": "Echu eo ho sontadeg",
"notification.reblog": "Gant {name} eo bet skignet ho toud",
"notification.quoted_update": "Gant {name} eo bet kemmet un embannadur meneget ganeoc'h",
"notification.reblog": "Gant {name} eo bet skignet hoc'h embannadur",
"notification.relationships_severance_event.learn_more": "Gouzout hiroc'h",
"notification.status": "Emañ {name} o paouez toudañ",
"notification.update": "Gant {name} ez eus bet kemmet un toud",
"notification.status": "Emañ {name} o paouez embann",
"notification.update": "Gant {name} ez eus bet kemmet un embannadur",
"notification_requests.accept": "Asantiñ",
"notification_requests.dismiss": "Diverkañ",
"notification_requests.edit_selection": "Kemmañ",
@ -408,18 +493,19 @@
"notifications.column_settings.alert": "Kemennoù war ar burev",
"notifications.column_settings.favourite": "Muiañ-karet:",
"notifications.column_settings.follow": "Heulierien nevez:",
"notifications.column_settings.follow_request": "Pedadoù heuliañ nevez :",
"notifications.column_settings.follow_request": "Rekedoù heuliañ nevez:",
"notifications.column_settings.group": "Strollañ",
"notifications.column_settings.mention": "Menegoù:",
"notifications.column_settings.poll": "Disoc'hoù ar sontadeg:",
"notifications.column_settings.push": "Kemennoù push",
"notifications.column_settings.quote": "Menegoù:",
"notifications.column_settings.reblog": "Skignadennoù:",
"notifications.column_settings.show": "Diskouez er bann",
"notifications.column_settings.sound": "Seniñ",
"notifications.column_settings.status": "Toudoù nevez :",
"notifications.column_settings.status": "Embannadurioù nevez:",
"notifications.column_settings.unread_notifications.category": "Kemennoù anlennet",
"notifications.column_settings.unread_notifications.highlight": "Uslinennañ ar c'hemennoù anlennet",
"notifications.column_settings.update": "Kemmoù :",
"notifications.column_settings.update": "Kemmoù:",
"notifications.filter.all": "Pep tra",
"notifications.filter.boosts": "Skignadennoù",
"notifications.filter.favourites": "Muiañ-karet",
@ -438,6 +524,8 @@
"notifications.policy.drop": "Tremen e-bioù",
"notifications.policy.filter": "Silañ",
"notifications.policy.filter_new_accounts_title": "Kontoù nevez",
"notifications.policy.filter_not_followers_title": "Tud na heuliont ket ac'hanoc'h",
"notifications.policy.filter_not_following_title": "Tud n'int ket heuliet ganeoc'h",
"notifications_permission_banner.enable": "Lezel kemennoù war ar burev",
"notifications_permission_banner.how_to_control": "Evit reseviñ kemennoù pa ne vez ket digoret Mastodon, lezelit kemennoù war ar burev. Gallout a rit kontrollañ peseurt eskemmoù a c'henel kemennoù war ar burev gant ar {icon} nozelenn a-us kentre ma'z int lezelet.",
"notifications_permission_banner.title": "Na vankit netra morse",
@ -447,8 +535,9 @@
"onboarding.profile.display_name": "Anv diskouezet",
"onboarding.profile.display_name_hint": "Hoc'h anv klok pe hoc'h anv fentus…",
"onboarding.profile.note": "Berr-ha-berr",
"onboarding.profile.note_hint": "Gallout a rit @menegiñ tud all pe #hashtagoù…",
"onboarding.profile.note_hint": "Gallout a rit @menegiñ tud all pe #geri-klik…",
"onboarding.profile.save_and_continue": "Enrollañ ha kenderc'hel",
"onboarding.profile.title": "Kefluniañ ar profil",
"onboarding.profile.upload_avatar": "Enporzhiañ ur skeudenn profil",
"password_confirmation.mismatching": "Disheñvel eo an daou c'her-termen-se",
"picture_in_picture.restore": "Adlakaat",
@ -463,12 +552,18 @@
"poll_button.add_poll": "Ouzhpennañ ur sontadeg",
"poll_button.remove_poll": "Dilemel ar sontadeg",
"privacy.change": "Cheñch prevezded an embannadur",
"privacy.direct.long": "Pep hini meneget en embannadur",
"privacy.private.long": "Hoc'h heulierien·ezed hepken",
"privacy.private.short": "Heulierien",
"privacy.public.short": "Publik",
"privacy.quote.anyone": "{visibility}, n'eus forzh piv a c'hall menegiñ",
"privacy.quote.disabled": "{visibility}, menegoù diweredekaet",
"privacy_policy.last_updated": "Hizivadenn ziwezhañ {date}",
"privacy_policy.title": "Reolennoù Prevezded",
"quote_error.poll": "N'haller ket menegiñ sontadegoù.",
"recommended": "Erbedet",
"refresh": "Freskaat",
"regeneration_indicator.preparing_your_home_feed": "O prientiñ ho red degemer…",
"relative_time.days": "{number}d",
"relative_time.full.days": "{number, plural, one {# devezh} two {# zevezh} few {# devezh} many {# a devezh} other {# devezh}} zo",
"relative_time.full.hours": "{number, plural, one {# eurvezh} two {# eurvezh} few {# eurvezh} many {# eur} other {# eurvezh}} zo",
@ -480,10 +575,12 @@
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}eil",
"relative_time.today": "hiziv",
"remove_quote_hint.button_label": "Mat eo",
"remove_quote_hint.title": "Fellout a ra deoc'h dilemel ho menegiñ an embannadur?",
"reply_indicator.cancel": "Nullañ",
"reply_indicator.poll": "Sontadeg",
"report.block": "Stankañ",
"report.block_explanation": "Ne vo ket gwelet toudoù ar gont-se ken. Ne welo ket ho toudoù ha ne c'hello ket ho heuliañ ken. Gouzout a raio eo bet stanket ganeoc'h.",
"report.block_explanation": "Ne vo ket gwelet embannadurioù ar gont-se ken. Ne welo ket hoc'h embannadurioù ha ne c'hello ket ho heuliañ ken. Gouzout a raio eo bet stanket ganeoc'h.",
"report.categories.legal": "Lezennel",
"report.categories.other": "All",
"report.categories.spam": "Spam",
@ -491,13 +588,13 @@
"report.category.subtitle": "Choazit ar pezh a glot ar gwellañ",
"report.category.title": "Lârit deomp petra c'hoarvez gant {type}",
"report.category.title_account": "profil",
"report.category.title_status": "an toud-mañ",
"report.category.title_status": "an embannadur-mañ",
"report.close": "Graet",
"report.comment.title": "Ha traoù all a rankfemp gouzout ?",
"report.forward": "Treuzkas da: {target}",
"report.forward_hint": "War ur servijer all emañ ar c'hont-se. Kas dezhañ un adskrid disanv eus an danevell ivez?",
"report.mute": "Kuzhat",
"report.mute_explanation": "Ne vo ket gwelet toudoù ar gont-se ken. Gwelet ho toudoù ha ho heuliañ a c'hello ha ne ouezo ket eo bet kuzhet ganeoc'h.",
"report.mute_explanation": "Ne vo ket gwelet embannadurioù ar gont-se ken. Gwelet hoc'h embannadurioù ha ho heuliañ a c'hello ha ne ouezo ket eo bet kuzhet ganeoc'h.",
"report.next": "War-raok",
"report.placeholder": "Askelennoù ouzhpenn",
"report.reasons.dislike": "Ne blij ket din",
@ -512,7 +609,7 @@
"report.rules.subtitle": "Diuzit an holl draoù a glot",
"report.rules.title": "Pesort reolennoù zo bet torret ?",
"report.statuses.subtitle": "Diuzit an holl draoù a glot",
"report.statuses.title": "Ha toudoù all zo a c'hallfe nerzhañ an disklêriadenn-se ?",
"report.statuses.title": "Hag embannadurioù all zo a c'hallfe nerzhañ an disklêriadenn-se?",
"report.submit": "Kinnig",
"report.target": "O tisklêriañ {target}",
"report.thanks.title": "Ne fell ket deoc'h gwelet an dra-se ?",
@ -528,9 +625,9 @@
"search.placeholder": "Klask",
"search.quick_action.account_search": "Profiloù a glot gant {x}",
"search.quick_action.go_to_account": "Mont d'ar profil {x}",
"search.quick_action.go_to_hashtag": "Mont d'an hashtag {x}",
"search.quick_action.go_to_hashtag": "Mont d'ar ger-klik {x}",
"search.quick_action.open_url": "Digeriñ an URL e-barzh Mastodon",
"search.quick_action.status_search": "Embannadurioù a glot gant {x}",
"search.quick_action.status_search": "Toudoù a glot gant {x}",
"search.search_or_paste": "Klask pe pegañ un URL",
"search_popout.full_text_search_disabled_message": "N'eo ket da gaout war {domain}.",
"search_popout.language_code": "Kod yezh ISO",
@ -541,7 +638,7 @@
"search_popout.user": "implijer·ez",
"search_results.accounts": "Profiloù",
"search_results.all": "Pep tra",
"search_results.hashtags": "Hashtagoù",
"search_results.hashtags": "Gerioù-klik",
"search_results.no_results": "Disoc'h ebet.",
"search_results.see_all": "Gwelet pep tra",
"search_results.statuses": "Toudoù",
@ -550,18 +647,21 @@
"server_banner.server_stats": "Stadegoù ar servijer :",
"sign_in_banner.create_account": "Krouiñ ur gont",
"sign_in_banner.sign_in": "Kevreañ",
"sign_in_banner.sso_redirect": "Kennaskañ pe lakaat hoc'h anv",
"sign_in_banner.sso_redirect": "Kevreañ pe lakaat hoc'h anv",
"status.admin_account": "Digeriñ etrefas evezhiañ evit @{name}",
"status.admin_domain": "Digeriñ an etrefas evezhiañ evit {domain}",
"status.admin_status": "Digeriñ an embannadenn e-barzh an etrefas evezhiañ",
"status.block": "Berzañ @{name}",
"status.all_disabled": "Diweredekaet eo ar skignañ hag ar menegiñ",
"status.block": "Stankañ @{name}",
"status.bookmark": "Ouzhpennañ d'ar sinedoù",
"status.cancel_reblog_private": "Nac'hañ ar skignadenn",
"status.cannot_quote": "N'haller ket menegiñ an embannadur-mañ",
"status.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet",
"status.context.load_new_replies": "Respontoù nevez zo",
"status.context.loading": "O kerc'hat muioc'h a respontoù",
"status.copy": "Eilañ liamm ar c'hannad",
"status.delete": "Dilemel",
"status.delete.success": "Embannadur dilamet",
"status.detailed_status": "Gwel kaozeadenn munudek",
"status.direct": "Menegiñ @{name} ent-prevez",
"status.direct_indicator": "Meneg prevez",
@ -582,13 +682,19 @@
"status.mute_conversation": "Kuzhat ar gaozeadenn",
"status.open": "Digeriñ ar c'hannad-mañ",
"status.pin": "Spilhennañ d'ar profil",
"status.quote": "Menegiñ",
"status.quote.cancel": "Nullañ ar menegiñ",
"status.quote_error.not_available": "Embannadur dihegerz",
"status.quote_policy_change": "Cheñch piv a c'hall menegiñ",
"status.read_more": "Lenn muioc'h",
"status.reblog": "Skignañ",
"status.reblog_private": "Skignañ gant ar weledenn gentañ",
"status.reblog_or_quote": "Skignañ pe menegiñ",
"status.reblog_private": "Skignañ d'hoc'h heulierien·ezed adarre",
"status.reblogged_by": "Skignet gant {name}",
"status.reblogs.empty": "Den ebet n'eus skignet ar c'hannad-mañ c'hoazh. Pa vo graet gant unan bennak e teuio war wel amañ.",
"status.redraft": "Diverkañ ha skrivañ en-dro",
"status.remove_bookmark": "Dilemel ar sined",
"status.remove_favourite": "Dilemel eus ar re vuiañ-karet",
"status.replied_to": "Respont da {name}",
"status.reply": "Respont",
"status.replyAll": "Respont d'ar gaozeadenn",
@ -607,6 +713,7 @@
"subscribed_languages.save": "Enrollañ ar cheñchamantoù",
"subscribed_languages.target": "Cheñch ar yezhoù koumanantet evit {target}",
"tabs_bar.home": "Degemer",
"tabs_bar.menu": "Lañser",
"tabs_bar.notifications": "Kemennoù",
"tabs_bar.publish": "Embannadenn nevez",
"tabs_bar.search": "Klask",
@ -638,6 +745,10 @@
"video.hide": "Kuzhat ar video",
"video.pause": "Paouez",
"video.play": "Lenn",
"visibility_modal.privacy_label": "Gwelusted",
"visibility_modal.quote_followers": "Tud koumanantet hepken",
"visibility_modal.quote_public": "Pep den"
"visibility_modal.quote_label": "Piv a c'hall menegiñ",
"visibility_modal.quote_nobody": "Me hepken",
"visibility_modal.quote_public": "Pep den",
"visibility_modal.save": "Enrollañ"
}

View File

@ -740,7 +740,6 @@
"privacy.public.long": "Tothom dins o fora Mastodon",
"privacy.public.short": "Públic",
"privacy.unlisted.additional": "Es comporta igual que públic, excepte que la publicació no apareixerà als canals en directe o etiquetes, l'explora o a la cerca de Mastodon, fins i tot si ho heu activat a nivell de compte.",
"privacy.unlisted.long": "Menys fanfàrries algorísmiques",
"privacy.unlisted.short": "Públic silenciós",
"privacy_policy.last_updated": "Darrera actualització {date}",
"privacy_policy.title": "Política de Privacitat",
@ -900,7 +899,6 @@
"status.read_more": "Més informació",
"status.reblog": "Impulsa",
"status.reblog_or_quote": "Impuls or cita",
"status.reblog_private": "Impulsa amb la visibilitat original",
"status.reblogged_by": "impulsat per {name}",
"status.reblogs": "{count, plural, one {impuls} other {impulsos}}",
"status.reblogs.empty": "Encara no ha impulsat ningú aquest tut. Quan algú ho faci, apareixerà aquí.",
@ -977,7 +975,6 @@
"visibility_modal.button_title": "Establiu la visibilitat",
"visibility_modal.header": "Visibilitat i interacció",
"visibility_modal.helper.direct_quoting": "No es poden citar mencions privades fetes a Mastondon.",
"visibility_modal.helper.privacy_editing": "No es pot canviar la visibilitat de les publicacions ja fetes.",
"visibility_modal.helper.private_quoting": "No es poden citar publicacions fetes a Mastodon només per a seguidors.",
"visibility_modal.helper.unlisted_quoting": "Quan la gent et citi les seves publicacions estaran amagades de les línies de temps de tendències.",
"visibility_modal.quote_followers": "Només seguidors",

View File

@ -496,7 +496,6 @@
"status.pin": "لکاندن لەسەر پرۆفایل",
"status.read_more": "زیاتر بخوێنەوە",
"status.reblog": "بەهێزکردن",
"status.reblog_private": "بەهێزکردن بۆ بینەرانی سەرەتایی",
"status.reblogged_by": "{name} توتی کردەوە",
"status.reblogs.empty": "کەس ئەم توتەی دووبارە نەتوتاندوە ،کاتێک کەسێک وا بکات، لێرە دەرئەکەون.",
"status.redraft": "سڕینەوەی و دووبارە ڕەشنووس",

View File

@ -288,7 +288,6 @@
"status.pin": "Puntarulà à u prufile",
"status.read_more": "Leghje di più",
"status.reblog": "Sparte",
"status.reblog_private": "Sparte à l'audienza uriginale",
"status.reblogged_by": "{name} hà spartutu",
"status.reblogs.empty": "Per avà nisunu hà spartutu u statutu. Quandu qualch'unu u sparterà, u so contu sarà mustratu quì.",
"status.redraft": "Sguassà è riscrive",

View File

@ -239,6 +239,10 @@
"confirmations.missing_alt_text.secondary": "Přesto odeslat",
"confirmations.missing_alt_text.title": "Přidat popisek?",
"confirmations.mute.confirm": "Skrýt",
"confirmations.quiet_post_quote_info.dismiss": "Znovu nepřípomínat",
"confirmations.quiet_post_quote_info.got_it": "Rozumím",
"confirmations.quiet_post_quote_info.message": "Při citování ztišeného veřejného příspěvku, váš příspěvek bude skrytý z os populárních příspěvků.",
"confirmations.quiet_post_quote_info.title": "Citování ztišených veřejných příspěvků",
"confirmations.redraft.confirm": "Smazat a přepsat",
"confirmations.redraft.message": "Jste si jistí, že chcete odstranit tento příspěvek a vytvořit z něj koncept? Oblíbené a boosty budou ztraceny a odpovědi na původní příspěvek ztratí kontext.",
"confirmations.redraft.title": "Smazat a přepracovat příspěvek na koncept?",
@ -745,7 +749,7 @@
"privacy.quote.disabled": "{visibility}, citování je zakázáno",
"privacy.quote.limited": "{visibility}, citování je omezené",
"privacy.unlisted.additional": "Chová se stejně jako veřejný, až na to, že se příspěvek neobjeví v živých kanálech nebo hashtazích, v objevování nebo vyhledávání na Mastodonu, a to i když je účet nastaven tak, aby se zde všude tyto příspěvky zobrazovaly.",
"privacy.unlisted.long": "Méně algoritmických fanfár",
"privacy.unlisted.long": "Skryté z výsledků vyhledávání na Mastodonu, trendujících příspěvků a veřejné časové osy",
"privacy.unlisted.short": "Ztišené veřejné",
"privacy_policy.last_updated": "Naposledy aktualizováno {date}",
"privacy_policy.title": "Zásady ochrany osobních údajů",
@ -768,6 +772,9 @@
"relative_time.minutes": "{number} m",
"relative_time.seconds": "{number} s",
"relative_time.today": "dnes",
"remove_quote_hint.button_label": "Chápu",
"remove_quote_hint.message": "Můžete to udělat z {icon} nabídky možností.",
"remove_quote_hint.title": "Chcete odstranit citovaný příspěvek?",
"reply_indicator.attachments": "{count, plural, one {{counter} příloha} few {{counter} přílohy} other {{counter} příloh}}",
"reply_indicator.cancel": "Zrušit",
"reply_indicator.poll": "Anketa",
@ -863,6 +870,7 @@
"status.block": "Blokovat @{name}",
"status.bookmark": "Přidat do záložek",
"status.cancel_reblog_private": "Zrušit boostnutí",
"status.cannot_quote": "Citování je na tomo příspěvku zakázáno",
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
"status.context.loading": "Hledání dalších odpovědí",
@ -909,7 +917,7 @@
"status.read_more": "Číst více",
"status.reblog": "Boostnout",
"status.reblog_or_quote": "Boostnout nebo citovat",
"status.reblog_private": "Boostnout s původní viditelností",
"status.reblog_private": "Sdílejte znovu se svými sledujícími",
"status.reblogged_by": "Uživatel {name} boostnul",
"status.reblogs": "{count, plural, one {boost} few {boosty} many {boostů} other {boostů}}",
"status.reblogs.empty": "Tento příspěvek ještě nikdo neboostnul. Pokud to někdo udělá, zobrazí se zde.",
@ -987,7 +995,8 @@
"visibility_modal.button_title": "Nastavit viditelnost",
"visibility_modal.header": "Viditelnost a interakce",
"visibility_modal.helper.direct_quoting": "Soukromé zmínky, které jsou vytvořeny na Mastodonu, nemohou být citovány ostatními.",
"visibility_modal.helper.privacy_editing": "Publikované příspěvky nemohou změnit svou viditelnost.",
"visibility_modal.helper.privacy_editing": "Viditelnost nelze změnit po publikování příspěvku.",
"visibility_modal.helper.privacy_private_self_quote": "Citace vlastních soukromých příspěvků nelze zveřejnit.",
"visibility_modal.helper.private_quoting": "Příspěvky pouze pro sledující, které jsou vytvořeny na Mastodonu, nemohou být citovány ostatními.",
"visibility_modal.helper.unlisted_quoting": "Když vás lidé citují, jejich příspěvek bude v časové ose populárních příspěvků také skryt.",
"visibility_modal.instructions": "Nastavte, kdo bude moci interagovat s tímto příspěvkem. Tyto nastavení též můžete změnit pro všechny budoucí příspěvky v <link>Nastavení > Výchozí nastavení příspěvků</link>.",

View File

@ -745,7 +745,7 @@
"privacy.quote.disabled": "{visibility}, dyfyniadau wedi'u hanalluogi",
"privacy.quote.limited": "{visibility}, dyfyniadau wedi'u cyfyngu",
"privacy.unlisted.additional": "Mae hwn yn ymddwyn yn union fel y cyhoeddus, ac eithrio na fydd y postiad yn ymddangos mewn ffrydiau byw neu hashnodau, archwilio, neu chwiliad Mastodon, hyd yn oed os ydych wedi eich cynnwys ar draws y cyfrif.",
"privacy.unlisted.long": "Llai o ddathliadau algorithmig",
"privacy.unlisted.long": "Wedi'i guddio rhag canlyniadau chwilio Mastodon, trendio, a llinellau amser cyhoeddus",
"privacy.unlisted.short": "Tewi'r cyhoeddus",
"privacy_policy.last_updated": "Diweddarwyd ddiwethaf ar {date}",
"privacy_policy.title": "Polisi Preifatrwydd",
@ -768,6 +768,7 @@
"relative_time.minutes": "{number} munud",
"relative_time.seconds": "{number} eiliad",
"relative_time.today": "heddiw",
"remove_quote_hint.button_label": "Iawn",
"reply_indicator.attachments": "{count, plural, one {# atodiad} other {# atodiad}}",
"reply_indicator.cancel": "Diddymu",
"reply_indicator.poll": "Pleidlais",
@ -908,7 +909,6 @@
"status.read_more": "Darllen rhagor",
"status.reblog": "Hybu",
"status.reblog_or_quote": "Hybu neu ddyfynnu",
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",
"status.reblogged_by": "Hybodd {name}",
"status.reblogs": "{count, plural, one {# hwb} other {# hwb}}",
"status.reblogs.empty": "Does neb wedi hybio'r post yma eto. Pan y bydd rhywun yn gwneud, byddent yn ymddangos yma.",
@ -986,7 +986,6 @@
"visibility_modal.button_title": "Gosod gwelededd",
"visibility_modal.header": "Gwelededd a rhyngweithio",
"visibility_modal.helper.direct_quoting": "Does dim modd dyfynnu crybwylliadau preifat ysgrifennwyd ar Mastodon.",
"visibility_modal.helper.privacy_editing": "Does dim modd newid gwelededd postiadau wedi'u cyhoeddi.",
"visibility_modal.helper.private_quoting": "Does dim modd dyfynnu crybwylliadau preifat ysgrifennwyd gan ddilynwyr.",
"visibility_modal.helper.unlisted_quoting": "Pan fydd pobl yn eich dyfynnu, bydd eu postiad hefyd yn cael ei guddio rhag llinellau amser sy'n trendio.",
"visibility_modal.quote_followers": "Dilynwyr yn unig",

View File

@ -239,6 +239,10 @@
"confirmations.missing_alt_text.secondary": "Læg op alligevel",
"confirmations.missing_alt_text.title": "Tilføj alt-tekst?",
"confirmations.mute.confirm": "Skjul",
"confirmations.quiet_post_quote_info.dismiss": "Påmind mig ikke igen",
"confirmations.quiet_post_quote_info.got_it": "Forstået",
"confirmations.quiet_post_quote_info.message": "Når du citerer et stille offentligt indlæg, vil dit indlæg blive skjult fra trendtidslinjer.",
"confirmations.quiet_post_quote_info.title": "Citering af stille offentlige indlæg",
"confirmations.redraft.confirm": "Slet og omskriv",
"confirmations.redraft.message": "Sikker på, at dette indlæg skal slettes og omskrives? Favoritter og fremhævelser går tabt, og svar til det oprindelige indlæg mister tilknytningen.",
"confirmations.redraft.title": "Slet og omskriv indlæg?",
@ -745,7 +749,7 @@
"privacy.quote.disabled": "{visibility}, citering deaktiveret",
"privacy.quote.limited": "{visibility}, citering begrænset",
"privacy.unlisted.additional": "Dette svarer til offentlig, bortset fra at indlægget ikke vises i live-feeds eller hashtags, udforsk eller Mastodon-søgning, selvom du har tilvalgt dette for kontoen.",
"privacy.unlisted.long": "Færre algoritmiske fanfarer",
"privacy.unlisted.long": "Skjult fra Mastodons søgeresultater, trender og offentlige tidslinjer",
"privacy.unlisted.short": "Offentlig (stille)",
"privacy_policy.last_updated": "Senest opdateret {date}",
"privacy_policy.title": "Privatlivspolitik",
@ -768,6 +772,9 @@
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.today": "i dag",
"remove_quote_hint.button_label": "Forstået",
"remove_quote_hint.message": "Du kan gøre dette fra menuen {icon} indstillinger.",
"remove_quote_hint.title": "Vil du fjerne dit citerede indlæg?",
"reply_indicator.attachments": "{count, plural, one {# vedhæftning} other {# vedhæftninger}}",
"reply_indicator.cancel": "Afbryd",
"reply_indicator.poll": "Afstemning",
@ -910,7 +917,7 @@
"status.read_more": "Læs mere",
"status.reblog": "Fremhæv",
"status.reblog_or_quote": "Fremhæv eller citér",
"status.reblog_private": "Fremhæv med oprindelig synlighed",
"status.reblog_private": "Del igen med dine følgere",
"status.reblogged_by": "{name} fremhævede",
"status.reblogs": "{count, plural, one {# fremhævelse} other {# fremhævelser}}",
"status.reblogs.empty": "Ingen har endnu fremhævet dette indlæg. Når nogen gør, vil det fremgå hér.",
@ -988,7 +995,7 @@
"visibility_modal.button_title": "Indstil synlighed",
"visibility_modal.header": "Synlighed og interaktion",
"visibility_modal.helper.direct_quoting": "Private omtaler forfattet på Mastodon kan ikke citeres af andre.",
"visibility_modal.helper.privacy_editing": "Publicerede indlægs synlighed kan ikke ændres.",
"visibility_modal.helper.privacy_editing": "Synlighed kan ikke ændres, efter at et indlæg er offentliggjort.",
"visibility_modal.helper.privacy_private_self_quote": "Selvcitater fra private indlæg kan ikke gøres offentlige.",
"visibility_modal.helper.private_quoting": "Kun-følger indlæg forfattet på Mastodon kan ikke citeres af andre.",
"visibility_modal.helper.unlisted_quoting": "Når folk citerer dig, vil deres indlæg også blive skjult fra trendtidslinjer.",

View File

@ -239,6 +239,10 @@
"confirmations.missing_alt_text.secondary": "Trotzdem veröffentlichen",
"confirmations.missing_alt_text.title": "Bildbeschreibung hinzufügen?",
"confirmations.mute.confirm": "Stummschalten",
"confirmations.quiet_post_quote_info.dismiss": "Nicht mehr anzeigen",
"confirmations.quiet_post_quote_info.got_it": "Verstanden",
"confirmations.quiet_post_quote_info.message": "Beim Zitieren eines Beitrags mit der Sichtbarkeit „Öffentlich (still)“ wird dein zitierter Beitrag ebenfalls nicht in den Trends und öffentlichen Timelines angezeigt.",
"confirmations.quiet_post_quote_info.title": "Zitieren eines Beitrags mit der Sichtbarkeit „Öffentlich (still)“",
"confirmations.redraft.confirm": "Löschen und neu erstellen",
"confirmations.redraft.message": "Möchtest du diesen Beitrag wirklich löschen und neu verfassen? Alle Favoriten sowie die bisher geteilten Beiträge werden verloren gehen und Antworten auf den ursprünglichen Beitrag verlieren den Zusammenhang.",
"confirmations.redraft.title": "Beitrag löschen und neu verfassen?",
@ -745,7 +749,7 @@
"privacy.quote.disabled": "{visibility} niemand darf zitieren",
"privacy.quote.limited": "{visibility} eingeschränktes Zitieren",
"privacy.unlisted.additional": "Das Verhalten ist wie bei „Öffentlich“, jedoch gibt es einige Einschränkungen. Der Beitrag wird nicht in „Live-Feeds“, „Erkunden“, Hashtags oder über die Mastodon-Suchfunktion auffindbar sein selbst wenn die zugehörige Einstellung aktiviert wurde.",
"privacy.unlisted.long": "Weniger im Algorithmus berücksichtigt",
"privacy.unlisted.long": "Verborgen vor Suchergebnissen, Trends und öffentlichen Timelines in Mastodon",
"privacy.unlisted.short": "Öffentlich (still)",
"privacy_policy.last_updated": "Stand: {date}",
"privacy_policy.title": "Datenschutzerklärung",
@ -768,6 +772,9 @@
"relative_time.minutes": "{number} Min.",
"relative_time.seconds": "{number} Sek.",
"relative_time.today": "heute",
"remove_quote_hint.button_label": "Verstanden",
"remove_quote_hint.message": "Klicke dafür im Beitrag auf „{icon} Mehr“.",
"remove_quote_hint.title": "Möchtest du aus dem zitierten Beitrag entfernt werden?",
"reply_indicator.attachments": "{count, plural, one {# Anhang} other {# Anhänge}}",
"reply_indicator.cancel": "Abbrechen",
"reply_indicator.poll": "Umfrage",
@ -910,7 +917,7 @@
"status.read_more": "Gesamten Beitrag anschauen",
"status.reblog": "Teilen",
"status.reblog_or_quote": "Teilen oder zitieren",
"status.reblog_private": "Mit der ursprünglichen Zielgruppe teilen",
"status.reblog_private": "Erneut mit deinen Followern teilen",
"status.reblogged_by": "{name} teilte",
"status.reblogs": "{count, plural, one {Mal geteilt} other {Mal geteilt}}",
"status.reblogs.empty": "Diesen Beitrag hat bisher noch niemand geteilt. Sobald es jemand tut, wird das Profil hier erscheinen.",
@ -988,7 +995,7 @@
"visibility_modal.button_title": "Sichtbarkeit festlegen",
"visibility_modal.header": "Sichtbarkeit und Interaktion",
"visibility_modal.helper.direct_quoting": "Private Erwähnungen, die auf Mastodon verfasst wurden, können nicht von anderen zitiert werden.",
"visibility_modal.helper.privacy_editing": "Die Sichtbarkeit bereits veröffentlichter Beiträge kann nachträglich nicht mehr geändert werden.",
"visibility_modal.helper.privacy_editing": "Die Sichtbarkeit eines bereits veröffentlichten Beitrags kann nachträglich nicht mehr geändert werden.",
"visibility_modal.helper.privacy_private_self_quote": "Beiträge mit privaten Erwähnungen können öffentlich nicht zitiert werden.",
"visibility_modal.helper.private_quoting": "Beiträge, die nur für deine Follower bestimmt sind und auf Mastodon verfasst wurden, können nicht von anderen zitiert werden.",
"visibility_modal.helper.unlisted_quoting": "Sollten dich andere zitieren, werden ihre zitierten Beiträge ebenfalls nicht in den Trends und öffentlichen Timelines angezeigt.",

View File

@ -239,6 +239,8 @@
"confirmations.missing_alt_text.secondary": "Δημοσίευση όπως και να ΄χει",
"confirmations.missing_alt_text.title": "Προσθήκη εναλλακτικού κειμένου;",
"confirmations.mute.confirm": "Αποσιώπηση",
"confirmations.quiet_post_quote_info.dismiss": "Μη μου το ξαναθυμίσεις",
"confirmations.quiet_post_quote_info.got_it": "Το κατάλαβα",
"confirmations.redraft.confirm": "Διαγραφή & ξαναγράψιμο",
"confirmations.redraft.message": "Σίγουρα θέλεις να σβήσεις αυτή την ανάρτηση και να την ξαναγράψεις; Οι προτιμήσεις και προωθήσεις θα χαθούν και οι απαντήσεις στην αρχική ανάρτηση θα μείνουν ορφανές.",
"confirmations.redraft.title": "Διαγραφή & επανασύνταξη;",
@ -492,6 +494,7 @@
"keyboard_shortcuts.open_media": "Άνοιγμα πολυμέσων",
"keyboard_shortcuts.pinned": "Άνοιγμα λίστας καρφιτσωμένων αναρτήσεων",
"keyboard_shortcuts.profile": "Άνοιγμα προφίλ συγγραφέα",
"keyboard_shortcuts.quote": "Παράθεση ανάρτησης",
"keyboard_shortcuts.reply": "Απάντηση στην ανάρτηση",
"keyboard_shortcuts.requests": "Άνοιγμα λίστας αιτημάτων ακολούθησης",
"keyboard_shortcuts.search": "Εστίαση στη γραμμή αναζήτησης",
@ -620,6 +623,7 @@
"notification.moderation_warning.action_suspend": "Ο λογαριασμός σου έχει ανασταλεί.",
"notification.own_poll": "Η δημοσκόπησή σου έληξε",
"notification.poll": "Μία ψηφοφορία στην οποία συμμετείχες έχει τελειώσει",
"notification.quoted_update": "Ο χρήστης {name} επεξεργάστηκε μία ανάρτηση που παρέθεσες",
"notification.reblog": "Ο/Η {name} ενίσχυσε την ανάρτηση σου",
"notification.reblog.name_and_others_with_link": "{name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> ενίσχυσαν την ανάρτησή σου",
"notification.relationships_severance_event": "Χάθηκε η σύνδεση με το {name}",
@ -743,7 +747,7 @@
"privacy.quote.disabled": "{visibility}, παραθέσεις απενεργοποιημένες",
"privacy.quote.limited": "{visibility}, παραθέσεις περιορισμένες",
"privacy.unlisted.additional": "Αυτό συμπεριφέρεται ακριβώς όπως το δημόσιο, εκτός από το ότι η ανάρτηση δεν θα εμφανιστεί σε ζωντανές ροές ή ετικέτες, εξερεύνηση ή αναζήτηση στο Mastodon, ακόμη και αν το έχεις επιλέξει για τον λογαριασμό σου.",
"privacy.unlisted.long": "Λιγότερα αλγοριθμικά κόλπα",
"privacy.unlisted.long": "Κρυμμένο από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές",
"privacy.unlisted.short": "Ήσυχα δημόσια",
"privacy_policy.last_updated": "Τελευταία ενημέρωση {date}",
"privacy_policy.title": "Πολιτική Απορρήτου",
@ -766,6 +770,9 @@
"relative_time.minutes": "{number}λ",
"relative_time.seconds": "{number}δ",
"relative_time.today": "σήμερα",
"remove_quote_hint.button_label": "Το 'πιασα",
"remove_quote_hint.message": "Μπορείτε να το κάνεις από το μενού επιλογών {icon}.",
"remove_quote_hint.title": "Θες να αφαιρέσεις την ανάρτηση που παρέθεσες;",
"reply_indicator.attachments": "{count, plural, one {# συνημμένο} other {# συνημμένα}}",
"reply_indicator.cancel": "Άκυρο",
"reply_indicator.poll": "Δημοσκόπηση",
@ -861,12 +868,14 @@
"status.block": "Αποκλεισμός @{name}",
"status.bookmark": "Σελιδοδείκτης",
"status.cancel_reblog_private": "Ακύρωση ενίσχυσης",
"status.cannot_quote": "Οι παραθέσεις είναι ανενεργές σ' αυτήν την ανάρτηση",
"status.cannot_reblog": "Αυτή η ανάρτηση δεν μπορεί να ενισχυθεί",
"status.context.load_new_replies": "Νέες απαντήσεις διαθέσιμες",
"status.context.loading": "Γίνεται έλεγχος για περισσότερες απαντήσεις",
"status.continued_thread": "Συνεχιζόμενο νήματος",
"status.copy": "Αντιγραφή συνδέσμου ανάρτησης",
"status.delete": "Διαγραφή",
"status.delete.success": "Η ανάρτηση διαγράφηκε",
"status.detailed_status": "Προβολή λεπτομερούς συζήτησης",
"status.direct": "Ιδιωτική επισήμανση @{name}",
"status.direct_indicator": "Ιδιωτική επισήμανση",
@ -896,12 +905,17 @@
"status.quote_error.pending_approval": "Ανάρτηση σε αναμονή",
"status.quote_error.pending_approval_popout.body": "Οι παραθέσεις που μοιράζονται στο Fediverse μπορεί να χρειαστούν χρόνο για να εμφανιστούν, καθώς διαφορετικοί διακομιστές έχουν διαφορετικά πρωτόκολλα.",
"status.quote_error.pending_approval_popout.title": "Παράθεση σε εκκρεμότητα; Μείνετε ψύχραιμοι",
"status.quote_followers_only": "Μόνο οι ακόλουθοι μπορούν να παραθέσουν αυτή την ανάρτηση",
"status.quote_manual_review": "Ο συντάκτης θα επανεξετάσει χειροκίνητα",
"status.quote_policy_change": "Αλλάξτε ποιός μπορεί να κάνει παράθεση",
"status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}",
"status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν",
"status.quotes": "{count, plural, one {# παράθεση} other {# παραθέσεις}}",
"status.quotes.empty": "Κανείς δεν έχει παραθέσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει, θα εμφανιστεί εδώ.",
"status.read_more": "Διάβασε περισότερα",
"status.reblog": "Ενίσχυση",
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
"status.reblog_or_quote": "Ενίσχυση ή παράθεση",
"status.reblog_private": "Μοιράσου ξανά με τους ακόλουθούς σου",
"status.reblogged_by": "{name} προώθησε",
"status.reblogs": "{count, plural, one {ενίσχυση} other {ενισχύσεις}}",
"status.reblogs.empty": "Κανείς δεν ενίσχυσε αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
@ -913,6 +927,7 @@
"status.reply": "Απάντησε",
"status.replyAll": "Απάντησε στο νήμα συζήτησης",
"status.report": "Αναφορά @{name}",
"status.request_quote": "Αίτημα για παράθεση",
"status.revoke_quote": "Αφαίρεση της ανάρτησης μου από την ανάρτηση του/της @{name}",
"status.sensitive_warning": "Ευαίσθητο περιεχόμενο",
"status.share": "Κοινοποίηση",
@ -977,9 +992,14 @@
"video.volume_up": "Αύξηση έντασης",
"visibility_modal.button_title": "Ορισμός ορατότητας",
"visibility_modal.header": "Ορατότητα και αλληλεπίδραση",
"visibility_modal.helper.privacy_editing": "Δημοσιευμένες αναρτήσεις δεν μπορούν να αλλάξουν την ορατότητά τους.",
"visibility_modal.helper.direct_quoting": "Ιδιωτικές αναφορές που έχουν συνταχθεί στο Mastodon δεν μπορούν να γίνουν παράθεση από άλλους.",
"visibility_modal.helper.privacy_editing": "Η ορατότητα δεν μπορεί να αλλάξει μετά τη δημοσίευση μιας ανάρτησης.",
"visibility_modal.helper.privacy_private_self_quote": "Αυτο-παραθέσεις ιδιωτικών αναρτήσεων δεν μπορούν να γίνουν δημόσιες.",
"visibility_modal.helper.unlisted_quoting": "Όταν οι άνθρωποι σας παραθέτουν, η ανάρτηση τους θα είναι επίσης κρυμμένη από τα δημοφιλή χρονοδιαγράμματα.",
"visibility_modal.privacy_label": "Ορατότητα",
"visibility_modal.quote_followers": "Μόνο ακόλουθοι",
"visibility_modal.quote_label": "Ποιος μπορεί να παραθέσει",
"visibility_modal.quote_nobody": "Μόνο εγώ",
"visibility_modal.quote_public": "Οποιοσδήποτε",
"visibility_modal.save": "Αποθήκευση"
}

View File

@ -731,7 +731,6 @@
"privacy.public.long": "Anyone on and off Mastodon",
"privacy.public.short": "Public",
"privacy.unlisted.additional": "This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.",
"privacy.unlisted.long": "Fewer algorithmic fanfares",
"privacy.unlisted.short": "Quiet public",
"privacy_policy.last_updated": "Last updated {date}",
"privacy_policy.title": "Privacy Policy",
@ -873,7 +872,6 @@
"status.quote_error.filtered": "Hidden due to one of your filters",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_private": "Boost with original visibility",
"status.reblogged_by": "{name} boosted",
"status.reblogs": "{count, plural, one {boost} other {boosts}}",
"status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.",

View File

@ -239,6 +239,10 @@
"confirmations.missing_alt_text.secondary": "Post anyway",
"confirmations.missing_alt_text.title": "Add alt text?",
"confirmations.mute.confirm": "Mute",
"confirmations.quiet_post_quote_info.dismiss": "Don't remind me again",
"confirmations.quiet_post_quote_info.got_it": "Got it",
"confirmations.quiet_post_quote_info.message": "When quoting a quiet public post, your post will be hidden from trending timelines.",
"confirmations.quiet_post_quote_info.title": "Quoting quiet public posts",
"confirmations.redraft.confirm": "Delete & redraft",
"confirmations.redraft.message": "Are you sure you want to delete this post and re-draft it? Favorites and boosts will be lost, and replies to the original post will be orphaned.",
"confirmations.redraft.title": "Delete & redraft post?",
@ -745,7 +749,7 @@
"privacy.quote.disabled": "{visibility}, quotes disabled",
"privacy.quote.limited": "{visibility}, quotes limited",
"privacy.unlisted.additional": "This behaves exactly like public, except the post will not appear in live feeds or hashtags, explore, or Mastodon search, even if you are opted-in account-wide.",
"privacy.unlisted.long": "Fewer algorithmic fanfares",
"privacy.unlisted.long": "Hidden from Mastodon search results, trending, and public timelines",
"privacy.unlisted.short": "Quiet public",
"privacy_policy.last_updated": "Last updated {date}",
"privacy_policy.title": "Privacy Policy",
@ -768,6 +772,9 @@
"relative_time.minutes": "{number}m",
"relative_time.seconds": "{number}s",
"relative_time.today": "today",
"remove_quote_hint.button_label": "Got it",
"remove_quote_hint.message": "You can do so from the {icon} options menu.",
"remove_quote_hint.title": "Want to remove your quoted post?",
"reply_indicator.attachments": "{count, plural, one {# attachment} other {# attachments}}",
"reply_indicator.cancel": "Cancel",
"reply_indicator.poll": "Poll",
@ -910,7 +917,7 @@
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_or_quote": "Boost or quote",
"status.reblog_private": "Boost with original visibility",
"status.reblog_private": "Share again with your followers",
"status.reblogged_by": "{name} boosted",
"status.reblogs": "{count, plural, one {boost} other {boosts}}",
"status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.",
@ -988,7 +995,7 @@
"visibility_modal.button_title": "Set visibility",
"visibility_modal.header": "Visibility and interaction",
"visibility_modal.helper.direct_quoting": "Private mentions authored on Mastodon can't be quoted by others.",
"visibility_modal.helper.privacy_editing": "Published posts cannot change their visibility.",
"visibility_modal.helper.privacy_editing": "Visibility can't be changed after a post is published.",
"visibility_modal.helper.privacy_private_self_quote": "Self-quotes of private posts cannot be made public.",
"visibility_modal.helper.private_quoting": "Follower-only posts authored on Mastodon can't be quoted by others.",
"visibility_modal.helper.unlisted_quoting": "When people quote you, their post will also be hidden from trending timelines.",

Some files were not shown because too many files have changed in this diff Show More