Merge pull request #3176 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to eb118d85238da8d0583868abab2756bb329742ba
This commit is contained in:
commit
cf7d1822f4
@ -1,6 +1,6 @@
|
|||||||
# This configuration was generated by
|
# This configuration was generated by
|
||||||
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
|
# `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
|
# The point is for the user to remove these configuration records
|
||||||
# one by one as the offenses are removed from the code base.
|
# one by one as the offenses are removed from the code base.
|
||||||
# Note that changes in the inspected code, or installation of new
|
# Note that changes in the inspected code, or installation of new
|
||||||
|
|||||||
@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
|
|||||||
import { action } from 'storybook/actions';
|
import { action } from 'storybook/actions';
|
||||||
|
|
||||||
import type { LocaleData } from '@/mastodon/locales';
|
import type { LocaleData } from '@/mastodon/locales';
|
||||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
import { reducerWithInitialState } from '@/mastodon/reducers';
|
||||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||||
|
|
||||||
// If you want to run the dark theme during development,
|
// If you want to run the dark theme during development,
|
||||||
// you can change the below to `/application.scss`
|
// you can change the below to `/application.scss`
|
||||||
import '../app/javascript/styles/mastodon-light.scss';
|
import '../app/javascript/styles/mastodon-light.scss';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||||
query: { as: 'json' },
|
query: { as: 'json' },
|
||||||
@ -49,12 +50,17 @@ const preview: Preview = {
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story, { parameters }) => {
|
(Story, { parameters, globals }) => {
|
||||||
|
const { locale } = globals as { locale: string };
|
||||||
const { state = {} } = parameters;
|
const { state = {} } = parameters;
|
||||||
let reducer = rootReducer;
|
const reducer = reducerWithInitialState(
|
||||||
if (typeof state === 'object' && state) {
|
{
|
||||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
meta: {
|
||||||
}
|
locale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state as Record<string, unknown>,
|
||||||
|
);
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer,
|
reducer,
|
||||||
middleware(getDefaultMiddleware) {
|
middleware(getDefaultMiddleware) {
|
||||||
|
|||||||
8
.storybook/styles.css
Normal file
8
.storybook/styles.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
12
Gemfile.lock
12
Gemfile.lock
@ -771,7 +771,7 @@ GEM
|
|||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.79.2)
|
rubocop (1.80.2)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
@ -791,17 +791,17 @@ GEM
|
|||||||
rubocop-i18n (3.2.3)
|
rubocop-i18n (3.2.3)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.72.1)
|
rubocop (>= 1.72.1)
|
||||||
rubocop-performance (1.25.0)
|
rubocop-performance (1.26.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
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)
|
rubocop-rails (2.33.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rspec (3.6.0)
|
rubocop-rspec (3.7.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (~> 1.72, >= 1.72.1)
|
rubocop (~> 1.72, >= 1.72.1)
|
||||||
rubocop-rspec_rails (2.31.0)
|
rubocop-rspec_rails (2.31.0)
|
||||||
@ -817,7 +817,7 @@ GEM
|
|||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.1.0)
|
||||||
rufus-scheduler (3.9.2)
|
rufus-scheduler (3.9.2)
|
||||||
fugit (~> 1.1, >= 1.11.1)
|
fugit (~> 1.1, >= 1.11.1)
|
||||||
safety_net_attestation (0.4.0)
|
safety_net_attestation (0.4.0)
|
||||||
@ -903,7 +903,7 @@ GEM
|
|||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.9.1)
|
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.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uri (1.0.3)
|
uri (1.0.3)
|
||||||
|
|||||||
82
app/controllers/activitypub/contexts_controller.rb
Normal file
82
app/controllers/activitypub/contexts_controller.rb
Normal 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
|
||||||
@ -49,8 +49,8 @@ module Admin
|
|||||||
|
|
||||||
def export_data
|
def export_data
|
||||||
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
CSV.generate(headers: export_headers, write_headers: true) do |content|
|
||||||
DomainAllow.allowed_domains.each do |instance|
|
DomainAllow.allowed_domains.each do |domain|
|
||||||
content << [instance.domain]
|
content << [domain]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -6,4 +6,10 @@ class Settings::Preferences::PostingDefaultsController < Settings::Preferences::
|
|||||||
def after_update_redirect_path
|
def after_update_redirect_path
|
||||||
settings_preferences_posting_defaults_path
|
settings_preferences_posting_defaults_path
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -145,6 +145,10 @@ function loaded() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDefaultQuotePrivacyFromPrivacy(
|
||||||
|
document.querySelector('#user_settings_attributes_default_privacy'),
|
||||||
|
);
|
||||||
|
|
||||||
const reactComponents = document.querySelectorAll('[data-component]');
|
const reactComponents = document.querySelectorAll('[data-component]');
|
||||||
|
|
||||||
if (reactComponents.length > 0) {
|
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
|
// Empty the honeypot fields in JS in case something like an extension
|
||||||
// automatically filled them.
|
// automatically filled them.
|
||||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type { Status } from '../models/status';
|
|||||||
|
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { focusCompose } from './compose';
|
import { focusCompose } from './compose';
|
||||||
|
import { openModal } from './modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
quoteErrorUpload: {
|
quoteErrorUpload: {
|
||||||
@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk(
|
|||||||
|
|
||||||
export const quoteComposeByStatus = createAppThunk(
|
export const quoteComposeByStatus = createAppThunk(
|
||||||
(status: Status, { dispatch, getState }) => {
|
(status: Status, { dispatch, getState }) => {
|
||||||
const composeState = getState().compose;
|
const state = getState();
|
||||||
|
const composeState = state.compose;
|
||||||
const mediaAttachments = composeState.get('media_attachments');
|
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')) {
|
if (composeState.get('poll')) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||||
@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk(
|
|||||||
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
||||||
) {
|
) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
||||||
|
} else if (
|
||||||
|
status.get('visibility') === 'unlisted' &&
|
||||||
|
!wasQuietPostHintModalDismissed
|
||||||
|
) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM_QUIET_QUOTE',
|
||||||
|
modalProps: { status },
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(quoteCompose(status));
|
dispatch(quoteCompose(status));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
|
|||||||
return normalResult;
|
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) {
|
export function normalizeStatus(status, normalOldStatus, settings) {
|
||||||
const normalStatus = { ...status };
|
const normalStatus = { ...status };
|
||||||
|
|
||||||
@ -78,6 +87,11 @@ export function normalizeStatus(status, normalOldStatus, settings) {
|
|||||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||||
normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
|
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://'))) {
|
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||||
normalStatus.url = null;
|
normalStatus.url = null;
|
||||||
}
|
}
|
||||||
@ -117,6 +131,11 @@ export function normalizeStatusTranslation(translation, status) {
|
|||||||
spoiler_text: translation.spoiler_text,
|
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;
|
return normalTranslation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,9 @@ import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick';
|
|||||||
const offset = [0, 4] as OffsetValue;
|
const offset = [0, 4] as OffsetValue;
|
||||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
export const AltTextBadge: React.FC<{
|
export const AltTextBadge: React.FC<{ description: string }> = ({
|
||||||
description: string;
|
description,
|
||||||
}> = ({ description }) => {
|
}) => {
|
||||||
const accessibilityId = useId();
|
const accessibilityId = useId();
|
||||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
|
|||||||
{({ props }) => (
|
{({ props }) => (
|
||||||
<div {...props} className='hover-card-controller'>
|
<div {...props} className='hover-card-controller'>
|
||||||
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
||||||
className='media-gallery__alt__popover dropdown-animation'
|
className='info-tooltip dropdown-animation'
|
||||||
role='region'
|
role='region'
|
||||||
id={accessibilityId}
|
id={accessibilityId}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ interface BaseProps
|
|||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
plain?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
block,
|
block,
|
||||||
secondary,
|
secondary,
|
||||||
|
plain,
|
||||||
compact,
|
compact,
|
||||||
dangerous,
|
dangerous,
|
||||||
loading,
|
loading,
|
||||||
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
<button
|
<button
|
||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
'button-secondary': secondary,
|
'button-secondary': secondary,
|
||||||
|
'button--plain': plain,
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
'button--dangerous': dangerous,
|
'button--dangerous': dangerous,
|
||||||
|
|||||||
@ -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 type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
|
||||||
@ -23,31 +18,48 @@ interface Props {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
export function useDismissableBannerState({ id }: Props) {
|
||||||
id,
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
children,
|
const dismissed: boolean = useAppSelector((state) =>
|
||||||
}) => {
|
/* eslint-disable-next-line */
|
||||||
const dismissed = useAppSelector((state) =>
|
|
||||||
state.settings.getIn(['dismissed_banners', id], false),
|
state.settings.getIn(['dismissed_banners', id], false),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isVisible, setIsVisible] = useState(
|
||||||
|
!bannerSettings.get(id) && !dismissed,
|
||||||
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
|
const dismiss = useCallback(() => {
|
||||||
const intl = useIntl();
|
setIsVisible(false);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
setVisible(false);
|
|
||||||
bannerSettings.set(id, true);
|
bannerSettings.set(id, true);
|
||||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||||
}, [id, dispatch]);
|
}, [id, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible && !dismissed) {
|
// Store legacy localStorage setting on server
|
||||||
|
if (!isVisible && !dismissed) {
|
||||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
icon='times'
|
icon='times'
|
||||||
iconComponent={CloseIcon}
|
iconComponent={CloseIcon}
|
||||||
title={intl.formatMessage(messages.dismiss)}
|
title={intl.formatMessage(messages.dismiss)}
|
||||||
onClick={handleDismiss}
|
onClick={dismiss}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
122
app/javascript/flavours/glitch/components/display_name/index.tsx
Normal file
122
app/javascript/flavours/glitch/components/display_name/index.tsx
Normal 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'>
|
||||||
|
|
||||||
|
<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'> {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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,22 +1,28 @@
|
|||||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, FC } 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 type { MessageDescriptor } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||||
|
|
||||||
import type { SelectItem } from '../dropdown_selector';
|
import type { SelectItem } from '../dropdown_selector';
|
||||||
import { DropdownSelector } from '../dropdown_selector';
|
import { DropdownSelector } from '../dropdown_selector';
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
|
||||||
|
import { matchWidth } from './utils';
|
||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
items: SelectItem[];
|
items: SelectItem[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
current: string;
|
current: string;
|
||||||
|
labelId: string;
|
||||||
|
descriptionId?: string;
|
||||||
emptyText?: MessageDescriptor;
|
emptyText?: MessageDescriptor;
|
||||||
classPrefix: string;
|
classPrefix: string;
|
||||||
}
|
}
|
||||||
@ -24,39 +30,59 @@ interface DropdownProps {
|
|||||||
export const Dropdown: FC<
|
export const Dropdown: FC<
|
||||||
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
||||||
> = ({
|
> = ({
|
||||||
title,
|
|
||||||
disabled,
|
disabled,
|
||||||
items,
|
items,
|
||||||
current,
|
current,
|
||||||
onChange,
|
onChange,
|
||||||
|
labelId,
|
||||||
|
descriptionId,
|
||||||
classPrefix,
|
classPrefix,
|
||||||
className,
|
className,
|
||||||
|
id,
|
||||||
...buttonProps
|
...buttonProps
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
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 [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
setOpen((prevOpen) => !prevOpen);
|
setOpen((prevOpen) => {
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
return !prevOpen;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentText = useMemo(
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
title={title}
|
id={buttonId}
|
||||||
|
aria-labelledby={`${labelId} ${buttonId}`}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={accessibilityId}
|
aria-controls={listboxId}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@ -69,23 +95,24 @@ export const Dropdown: FC<
|
|||||||
)}
|
)}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
{currentText ?? (
|
{currentText}
|
||||||
<FormattedMessage
|
<Icon
|
||||||
id='dropdown.empty'
|
id='unfold-icon'
|
||||||
defaultMessage='Select an option'
|
icon={UnfoldMoreIcon}
|
||||||
|
className={`${classPrefix}__icon`}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Overlay
|
<Overlay
|
||||||
show={open}
|
show={open}
|
||||||
offset={[0, 4]}
|
offset={[0, 0]}
|
||||||
placement='bottom-start'
|
placement='bottom-start'
|
||||||
onHide={handleClose}
|
onHide={handleClose}
|
||||||
flip
|
flip
|
||||||
target={buttonRef.current}
|
target={buttonRef.current}
|
||||||
popperConfig={{
|
popperConfig={{
|
||||||
strategy: 'fixed',
|
strategy: 'fixed',
|
||||||
|
modifiers: [matchWidth],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
@ -96,7 +123,7 @@ export const Dropdown: FC<
|
|||||||
`${classPrefix}__dropdown`,
|
`${classPrefix}__dropdown`,
|
||||||
placement,
|
placement,
|
||||||
)}
|
)}
|
||||||
id={accessibilityId}
|
id={listboxId}
|
||||||
>
|
>
|
||||||
<DropdownSelector
|
<DropdownSelector
|
||||||
items={items}
|
items={items}
|
||||||
|
|||||||
17
app/javascript/flavours/glitch/components/dropdown/utils.ts
Normal file
17
app/javascript/flavours/glitch/components/dropdown/utils.ts
Normal 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`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -36,6 +36,7 @@ import {
|
|||||||
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { Icon } from './icon';
|
||||||
import type { IconProp } from './icon';
|
import type { IconProp } from './icon';
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from './icon_button';
|
||||||
|
|
||||||
@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
|
|||||||
onItemClick?: ItemClickFn<Item>;
|
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,>({
|
export const DropdownMenu = <Item = MenuItem,>({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
@ -200,7 +222,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
@ -211,8 +233,9 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
@ -227,7 +250,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -239,7 +262,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -247,6 +270,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames('dropdown-menu__item', {
|
className={classNames('dropdown-menu__item', {
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
key={`${text}-${i}`}
|
||||||
|
|||||||
@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const nodeRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||||
const [currentValue, setCurrentValue] = useState(value);
|
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(
|
const handleClick = useCallback(
|
||||||
(
|
(
|
||||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||||
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index + 1] ??
|
listRef.current?.children[index + 1] ??
|
||||||
nodeRef.current?.firstElementChild;
|
listRef.current?.firstElementChild;
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index - 1] ??
|
listRef.current?.children[index - 1] ??
|
||||||
nodeRef.current?.lastElementChild;
|
listRef.current?.lastElementChild;
|
||||||
break;
|
break;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index - 1] ??
|
listRef.current?.children[index - 1] ??
|
||||||
nodeRef.current?.lastElementChild;
|
listRef.current?.lastElementChild;
|
||||||
} else {
|
} else {
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index + 1] ??
|
listRef.current?.children[index + 1] ??
|
||||||
nodeRef.current?.firstElementChild;
|
listRef.current?.firstElementChild;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
element = nodeRef.current?.firstElementChild;
|
element = listRef.current?.firstElementChild;
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
element = nodeRef.current?.lastElementChild;
|
element = listRef.current?.lastElementChild;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
[items, onClose, handleClick, setCurrentValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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('click', handleDocumentClick, { capture: true });
|
||||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
|
||||||
focusedItemRef.current?.focus({ preventScroll: true });
|
focusedItemRef.current?.focus({ preventScroll: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
listenerOptions,
|
listenerOptions,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [handleDocumentClick]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul style={style} role='listbox' ref={nodeRef}>
|
<ul style={style} role='listbox' ref={listRef}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li
|
<li
|
||||||
role='option'
|
role='option'
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
import type { StatusVisibility } from '@/flavours/glitch/api_types/statuses';
|
||||||
import { statusFactoryState } from '@/testing/factories';
|
import { statusFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
|
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||||
|
|
||||||
interface StoryProps {
|
interface StoryProps {
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
@ -13,7 +13,7 @@ interface StoryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Status/ReblogButton',
|
title: 'Components/Status/BoostButton',
|
||||||
args: {
|
args: {
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
quoteAllowed: true,
|
quoteAllowed: true,
|
||||||
@ -38,7 +38,7 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<StatusReblogButton
|
<StatusBoostButton
|
||||||
status={argsToStatus(args)}
|
status={argsToStatus(args)}
|
||||||
counters={args.reblogCount > 0}
|
counters={args.reblogCount > 0}
|
||||||
/>
|
/>
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -22,12 +22,13 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend
|
|||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
||||||
|
|
||||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||||
import { me } from '../initial_state';
|
import { me } from '../../initial_state';
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
import { RelativeTimestamp } from './relative_timestamp';
|
import { RelativeTimestamp } from '../relative_timestamp';
|
||||||
import { isFeatureEnabled } from '../utils/environment';
|
import { isFeatureEnabled } from '../../utils/environment';
|
||||||
import { ReblogButton } from './status/reblog_button';
|
import { BoostButton } from '../status/boost_button';
|
||||||
|
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
identity: identityContextPropShape,
|
identity: identityContextPropShape,
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
quotedAccountId: PropTypes.string,
|
quotedAccountId: PropTypes.string,
|
||||||
|
contextType: PropTypes.string,
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
@ -213,7 +215,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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 { signedIn, permissions } = this.props.identity;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
@ -221,6 +223,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
const mutingConversation = status.get('muted');
|
const mutingConversation = status.get('muted');
|
||||||
const writtenByMe = status.getIn(['account', 'id']) === me;
|
const writtenByMe = status.getIn(['account', 'id']) === me;
|
||||||
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
||||||
|
const isQuotingMe = quotedAccountId === me;
|
||||||
|
|
||||||
let menu = [];
|
let menu = [];
|
||||||
let reblogIcon = 'retweet';
|
let reblogIcon = 'retweet';
|
||||||
@ -271,7 +274,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
menu.push(null);
|
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 });
|
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 bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark);
|
||||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
||||||
|
|
||||||
|
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='status__action-bar'>
|
<div className='status__action-bar'>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
@ -334,7 +339,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<ReblogButton status={status} counters={withCounters} />
|
<BoostButton status={status} counters={withCounters} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<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} />
|
<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,7 +350,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
|
|
||||||
{filterButton}
|
{filterButton}
|
||||||
|
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
|
||||||
|
{(dismissQuoteHint) => (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
status={status}
|
status={status}
|
||||||
@ -355,8 +361,13 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
direction='right'
|
direction='right'
|
||||||
ariaLabel={intl.formatMessage(messages.more)}
|
ariaLabel={intl.formatMessage(messages.more)}
|
||||||
|
onOpen={() => {
|
||||||
|
dismissQuoteHint();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</RemoveQuoteHint>
|
||||||
|
|
||||||
<div className='status__action-bar-spacer' />
|
<div className='status__action-bar-spacer' />
|
||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -235,16 +235,6 @@ class StatusContent extends PureComponent {
|
|||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
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 }) => {
|
handleMouseEnter = ({ currentTarget }) => {
|
||||||
|
|||||||
@ -145,6 +145,10 @@ function loaded() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDefaultQuotePrivacyFromPrivacy(
|
||||||
|
document.querySelector('#user_settings_attributes_default_privacy'),
|
||||||
|
);
|
||||||
|
|
||||||
const reactComponents = document.querySelectorAll('[data-component]');
|
const reactComponents = document.querySelectorAll('[data-component]');
|
||||||
|
|
||||||
if (reactComponents.length > 0) {
|
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
|
// Empty the honeypot fields in JS in case something like an extension
|
||||||
// automatically filled them.
|
// automatically filled them.
|
||||||
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const messages = defineMessages({
|
|||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
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_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||||
|
|||||||
@ -79,10 +79,12 @@ const visibilityOptions = {
|
|||||||
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { visibility, quotePolicy } = useAppSelector((state) => ({
|
const quotePolicy = useAppSelector(
|
||||||
visibility: state.compose.get('privacy') as StatusVisibility,
|
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
|
||||||
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
|
);
|
||||||
}));
|
const visibility = useAppSelector(
|
||||||
|
(state) => state.compose.get('privacy') as StatusVisibility,
|
||||||
|
);
|
||||||
|
|
||||||
const { icon, iconComponent } = useMemo(() => {
|
const { icon, iconComponent } = useMemo(() => {
|
||||||
const option = visibilityOptions[visibility];
|
const option = visibilityOptions[visibility];
|
||||||
|
|||||||
@ -12,16 +12,21 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
|||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
as?: Element;
|
||||||
|
shallow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModernEmojiHTML = <Element extends ElementType>({
|
export const ModernEmojiHTML = ({
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
htmlString,
|
htmlString,
|
||||||
as: asElement, // Rename for syntax highlighting
|
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||||
|
shallow,
|
||||||
...props
|
...props
|
||||||
}: EmojiHTMLProps<Element>) => {
|
}: EmojiHTMLProps<ElementType>) => {
|
||||||
const Wrapper = asElement ?? 'div';
|
const emojifiedHtml = useEmojify({
|
||||||
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
text: htmlString,
|
||||||
|
extraEmojis,
|
||||||
|
deep: !shallow,
|
||||||
|
});
|
||||||
|
|
||||||
if (emojifiedHtml === null) {
|
if (emojifiedHtml === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
|||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
|
import { emojifyElement, emojifyText } from './render';
|
||||||
import type {
|
import type {
|
||||||
CustomEmojiMapArg,
|
CustomEmojiMapArg,
|
||||||
EmojiAppState,
|
EmojiAppState,
|
||||||
@ -15,7 +16,17 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { stringHasAnyEmoji } from './utils';
|
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 [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
const appState = useEmojiAppState();
|
const appState = useEmojiAppState();
|
||||||
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
|||||||
|
|
||||||
const emojify = useCallback(
|
const emojify = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
|
let result: string | null = null;
|
||||||
|
if (deep) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = input;
|
wrapper.innerHTML = input;
|
||||||
const { emojifyElement } = await import('./render');
|
if (await emojifyElement(wrapper, appState, extra)) {
|
||||||
const result = await emojifyElement(wrapper, appState, extra);
|
result = wrapper.innerHTML;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await emojifyText(text, appState, extra);
|
||||||
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
setEmojifiedText(result.innerHTML);
|
setEmojifiedText(result);
|
||||||
} else {
|
} else {
|
||||||
setEmojifiedText(input);
|
setEmojifiedText(input);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState, extra],
|
[appState, deep, extra, text],
|
||||||
);
|
);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||||
|
|||||||
@ -143,7 +143,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='app-form__toggle__toggle'>
|
<div className='app-form__toggle__toggle'>
|
||||||
<div>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@ -153,7 +152,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -36,7 +36,7 @@ const messages = defineMessages({
|
|||||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||||
reblog_private: {
|
reblog_private: {
|
||||||
id: 'status.reblog_private',
|
id: 'status.reblog_private',
|
||||||
defaultMessage: 'Boost with original visibility',
|
defaultMessage: 'Share again with your followers',
|
||||||
},
|
},
|
||||||
cancel_reblog_private: {
|
cancel_reblog_private: {
|
||||||
id: 'status.cancel_reblog_private',
|
id: 'status.cancel_reblog_private',
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import { IconButton } from '../../../components/icon_button';
|
|||||||
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
|
||||||
import { me } from '../../../initial_state';
|
import { me } from '../../../initial_state';
|
||||||
import { isFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
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({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
@ -254,7 +254,7 @@ class ActionBar extends PureComponent {
|
|||||||
<div className='detailed-status__action-bar'>
|
<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'><IconButton title={intl.formatMessage(messages.reply)} icon={replyIcon} iconComponent={replyIconComponent} onClick={this.handleReplyClick} /></div>
|
||||||
<div className='detailed-status__button'>
|
<div className='detailed-status__button'>
|
||||||
<ReblogButton status={status} />
|
<BoostButton status={status} />
|
||||||
</div>
|
</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='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>
|
<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>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DropdownMenuItemContent } from 'flavours/glitch/components/dropdown_menu';
|
||||||
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
||||||
import {
|
import {
|
||||||
isActionItem,
|
isActionItem,
|
||||||
@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{
|
|||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
if (isActionItem(option)) {
|
if (isActionItem(option)) {
|
||||||
element = (
|
element = (
|
||||||
<button onClick={onClick} data-index={i}>
|
<button onClick={onClick} data-index={i} disabled={disabled}>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
element = (
|
element = (
|
||||||
<Link to={option.to} onClick={onClick} data-index={i}>
|
<Link to={option.to} onClick={onClick} data-index={i}>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames({
|
className={classNames('dropdown-menu__item', {
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
key={`${text}-${i}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -51,10 +51,6 @@ export const ConfirmationModal: React.FC<
|
|||||||
onSecondary?.();
|
onSecondary?.();
|
||||||
}, [onClose, onSecondary]);
|
}, [onClose, onSecondary]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal safety-action-modal'>
|
<div className='modal-root__modal safety-action-modal'>
|
||||||
<div className='safety-action-modal__top'>
|
<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__bottom'>
|
||||||
<div className='safety-action-modal__actions'>
|
<div className='safety-action-modal__actions'>
|
||||||
<button onClick={handleCancel} className='link-button'>
|
<button onClick={onClose} className='link-button'>
|
||||||
{cancel ?? (
|
{cancel ?? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='confirmation_modal.cancel'
|
id='confirmation_modal.cancel'
|
||||||
|
|||||||
@ -11,3 +11,4 @@ export { ConfirmLogOutModal } from './log_out';
|
|||||||
export { ConfirmFollowToListModal } from './follow_to_list';
|
export { ConfirmFollowToListModal } from './follow_to_list';
|
||||||
export { ConfirmMissingAltTextModal } from './missing_alt_text';
|
export { ConfirmMissingAltTextModal } from './missing_alt_text';
|
||||||
export { ConfirmRevokeQuoteModal } from './revoke_quote';
|
export { ConfirmRevokeQuoteModal } from './revoke_quote';
|
||||||
|
export { QuietPostQuoteInfoModal } from './quiet_post_quote_info';
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -201,8 +201,6 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
preview={image.get('preview_url')}
|
preview={image.get('preview_url')}
|
||||||
blurhash={image.get('blurhash')}
|
blurhash={image.get('blurhash')}
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={image.get('width')}
|
|
||||||
height={image.get('height')}
|
|
||||||
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
startTime={currentTime || 0}
|
startTime={currentTime || 0}
|
||||||
startPlaying={autoPlay || false}
|
startPlaying={autoPlay || false}
|
||||||
@ -218,8 +216,6 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<GIFV
|
<GIFV
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
key={image.get('url')}
|
key={image.get('url')}
|
||||||
alt={description}
|
alt={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import {
|
|||||||
ConfirmFollowToListModal,
|
ConfirmFollowToListModal,
|
||||||
ConfirmMissingAltTextModal,
|
ConfirmMissingAltTextModal,
|
||||||
ConfirmRevokeQuoteModal,
|
ConfirmRevokeQuoteModal,
|
||||||
|
QuietPostQuoteInfoModal,
|
||||||
} from './confirmation_modals';
|
} from './confirmation_modals';
|
||||||
import DeprecatedSettingsModal from './deprecated_settings_modal';
|
import DeprecatedSettingsModal from './deprecated_settings_modal';
|
||||||
import DoodleModal from './doodle_modal';
|
import DoodleModal from './doodle_modal';
|
||||||
@ -68,6 +69,7 @@ export const MODAL_COMPONENTS = {
|
|||||||
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
||||||
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
||||||
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
||||||
|
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
|
||||||
'MUTE': MuteModal,
|
'MUTE': MuteModal,
|
||||||
'BLOCK': BlockModal,
|
'BLOCK': BlockModal,
|
||||||
'DOMAIN_BLOCK': DomainBlockModal,
|
'DOMAIN_BLOCK': DomainBlockModal,
|
||||||
|
|||||||
@ -163,7 +163,6 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
value: 'unlisted',
|
value: 'unlisted',
|
||||||
text: intl.formatMessage(privacyMessages.unlisted_short),
|
text: intl.formatMessage(privacyMessages.unlisted_short),
|
||||||
meta: intl.formatMessage(privacyMessages.unlisted_long),
|
meta: intl.formatMessage(privacyMessages.unlisted_long),
|
||||||
extra: intl.formatMessage(privacyMessages.unlisted_extra),
|
|
||||||
icon: 'unlock',
|
icon: 'unlock',
|
||||||
iconComponent: QuietTimeIcon,
|
iconComponent: QuietTimeIcon,
|
||||||
},
|
},
|
||||||
@ -199,8 +198,11 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
onClose();
|
onClose();
|
||||||
}, [onChange, onClose, visibility, quotePolicy]);
|
}, [onChange, onClose, visibility, quotePolicy]);
|
||||||
|
|
||||||
const privacyDropdownId = useId();
|
const uniqueId = useId();
|
||||||
const quoteDropdownId = useId();
|
const visibilityLabelId = `${uniqueId}-visibility-label`;
|
||||||
|
const visibilityDescriptionId = `${uniqueId}-visibility-desc`;
|
||||||
|
const quoteLabelId = `${uniqueId}-quote-label`;
|
||||||
|
const quoteDescriptionId = `${uniqueId}-quote-desc`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal dialog-modal visibility-modal'>
|
<div className='modal-root__modal dialog-modal visibility-modal'>
|
||||||
@ -235,28 +237,36 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='dialog-modal__content__form'>
|
<div className='dialog-modal__content__form'>
|
||||||
<label
|
<div
|
||||||
htmlFor={privacyDropdownId}
|
className={classNames('visibility-dropdown', {
|
||||||
className={classNames('visibility-dropdown__label', {
|
|
||||||
disabled: disableVisibility,
|
disabled: disableVisibility,
|
||||||
})}
|
})}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label
|
||||||
|
className='visibility-dropdown__label'
|
||||||
|
id={visibilityLabelId}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.privacy_label'
|
id='visibility_modal.privacy_label'
|
||||||
defaultMessage='Visibility'
|
defaultMessage='Visibility'
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={visibilityItems}
|
items={visibilityItems}
|
||||||
classPrefix='visibility-dropdown'
|
|
||||||
current={visibility}
|
current={visibility}
|
||||||
onChange={handleVisibilityChange}
|
onChange={handleVisibilityChange}
|
||||||
title={intl.formatMessage(privacyMessages.change_privacy)}
|
labelId={visibilityLabelId}
|
||||||
|
descriptionId={visibilityDescriptionId}
|
||||||
|
classPrefix='visibility-dropdown'
|
||||||
disabled={disableVisibility}
|
disabled={disableVisibility}
|
||||||
id={privacyDropdownId}
|
|
||||||
/>
|
/>
|
||||||
{!!statusId && (
|
{!!statusId && (
|
||||||
<p className='visibility-dropdown__helper'>
|
<p
|
||||||
|
className='visibility-dropdown__helper'
|
||||||
|
id='visibilityDescriptionId'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.privacy_editing'
|
id='visibility_modal.helper.privacy_editing'
|
||||||
defaultMessage="Visibility can't be changed after a post is published."
|
defaultMessage="Visibility can't be changed after a post is published."
|
||||||
@ -264,37 +274,47 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!statusId && disablePublicVisibilities && (
|
{!statusId && disablePublicVisibilities && (
|
||||||
<p className='visibility-dropdown__helper'>
|
<p
|
||||||
|
className='visibility-dropdown__helper'
|
||||||
|
id='visibilityDescriptionId'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.privacy_private_self_quote'
|
id='visibility_modal.helper.privacy_private_self_quote'
|
||||||
defaultMessage='Self-quotes of private posts cannot be made public.'
|
defaultMessage='Self-quotes of private posts cannot be made public.'
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<label
|
<div
|
||||||
htmlFor={quoteDropdownId}
|
className={classNames('visibility-dropdown', {
|
||||||
className={classNames('visibility-dropdown__label', {
|
|
||||||
disabled: disableQuotePolicy,
|
disabled: disableQuotePolicy,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label className='visibility-dropdown__label' id={quoteLabelId}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.quote_label'
|
id='visibility_modal.quote_label'
|
||||||
defaultMessage='Who can quote'
|
defaultMessage='Who can quote'
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={quoteItems}
|
items={quoteItems}
|
||||||
onChange={handleQuotePolicyChange}
|
|
||||||
classPrefix='visibility-dropdown'
|
|
||||||
current={disableQuotePolicy ? 'nobody' : quotePolicy}
|
current={disableQuotePolicy ? 'nobody' : quotePolicy}
|
||||||
title={intl.formatMessage(messages.buttonTitle)}
|
onChange={handleQuotePolicyChange}
|
||||||
|
labelId={quoteLabelId}
|
||||||
|
descriptionId={quoteDescriptionId}
|
||||||
|
classPrefix='visibility-dropdown'
|
||||||
disabled={disableQuotePolicy}
|
disabled={disableQuotePolicy}
|
||||||
id={quoteDropdownId}
|
|
||||||
/>
|
/>
|
||||||
<QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
|
<QuotePolicyHelper
|
||||||
</label>
|
policy={quotePolicy}
|
||||||
|
visibility={visibility}
|
||||||
|
className='visibility-dropdown__helper'
|
||||||
|
id={quoteDescriptionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='dialog-modal__content__actions'>
|
<div className='dialog-modal__content__actions'>
|
||||||
<Button onClick={onClose} secondary>
|
<Button onClick={onClose} secondary>
|
||||||
@ -317,42 +337,44 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
);
|
);
|
||||||
VisibilityModal.displayName = 'VisibilityModal';
|
VisibilityModal.displayName = 'VisibilityModal';
|
||||||
|
|
||||||
const QuotePolicyHelper: FC<{
|
const QuotePolicyHelper: FC<
|
||||||
|
{
|
||||||
policy: ApiQuotePolicy;
|
policy: ApiQuotePolicy;
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
}> = ({ policy, visibility }) => {
|
} & React.ComponentPropsWithoutRef<'p'>
|
||||||
|
> = ({ policy, visibility, ...otherProps }) => {
|
||||||
|
let hintText: React.ReactElement | undefined;
|
||||||
|
|
||||||
if (visibility === 'unlisted' && policy !== 'nobody') {
|
if (visibility === 'unlisted' && policy !== 'nobody') {
|
||||||
return (
|
hintText = (
|
||||||
<p className='visibility-dropdown__helper'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.unlisted_quoting'
|
id='visibility_modal.helper.unlisted_quoting'
|
||||||
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
|
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
|
||||||
/>
|
/>
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility === 'private') {
|
if (visibility === 'private') {
|
||||||
return (
|
hintText = (
|
||||||
<p className='visibility-dropdown__helper'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.private_quoting'
|
id='visibility_modal.helper.private_quoting'
|
||||||
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
|
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
|
||||||
/>
|
/>
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility === 'direct') {
|
if (visibility === 'direct') {
|
||||||
return (
|
hintText = (
|
||||||
<p className='visibility-dropdown__helper'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.direct_quoting'
|
id='visibility_modal.helper.direct_quoting'
|
||||||
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
|
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
|
||||||
/>
|
/>
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hintText) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p {...otherProps}>{hintText}</p>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -94,8 +94,7 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
layout: state.getIn(['meta', 'layout']),
|
layout: state.getIn(['meta', 'layout']),
|
||||||
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 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,
|
||||||
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
|
|
||||||
canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
|
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']),
|
isWide: state.getIn(['local_settings', 'stretch']),
|
||||||
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
|
fullWidthColumns: state.getIn(['local_settings', 'fullwidth_columns']),
|
||||||
@ -252,8 +251,7 @@ class UI extends PureComponent {
|
|||||||
fullWidthColumns: PropTypes.bool,
|
fullWidthColumns: PropTypes.bool,
|
||||||
systemFontUi: PropTypes.bool,
|
systemFontUi: PropTypes.bool,
|
||||||
isComposing: PropTypes.bool,
|
isComposing: PropTypes.bool,
|
||||||
hasComposingText: PropTypes.bool,
|
hasComposingContents: PropTypes.bool,
|
||||||
hasMediaAttachments: PropTypes.bool,
|
|
||||||
canUploadMore: PropTypes.bool,
|
canUploadMore: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
unreadNotifications: PropTypes.number,
|
unreadNotifications: PropTypes.number,
|
||||||
@ -272,11 +270,11 @@ class UI extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleBeforeUnload = e => {
|
handleBeforeUnload = e => {
|
||||||
const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
|
const { intl, dispatch, hasComposingContents } = this.props;
|
||||||
|
|
||||||
dispatch(synchronouslySubmitMarkers());
|
dispatch(synchronouslySubmitMarkers());
|
||||||
|
|
||||||
if (hasComposingText || hasMediaAttachments) {
|
if (hasComposingContents) {
|
||||||
// Setting returnValue to any string causes confirmation dialog.
|
// Setting returnValue to any string causes confirmation dialog.
|
||||||
// Many browsers no longer display this text to users,
|
// Many browsers no longer display this text to users,
|
||||||
// but we set user-friendly message for other browsers, e.g. Edge.
|
// but we set user-friendly message for other browsers, e.g. Edge.
|
||||||
|
|||||||
@ -1,7 +1,13 @@
|
|||||||
import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react';
|
import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react';
|
||||||
|
|
||||||
|
import type { IconProp } from '../components/icon';
|
||||||
|
|
||||||
interface BaseMenuItem {
|
interface BaseMenuItem {
|
||||||
text: string;
|
text: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: IconProp;
|
||||||
|
highlighted?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -103,9 +103,9 @@ const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
|
|||||||
export const rootReducer = combineReducers(reducers, RootStateRecord);
|
export const rootReducer = combineReducers(reducers, RootStateRecord);
|
||||||
|
|
||||||
export function reducerWithInitialState(
|
export function reducerWithInitialState(
|
||||||
stateOverrides: Record<string, unknown> = {},
|
...stateOverrides: Record<string, unknown>[]
|
||||||
) {
|
) {
|
||||||
const initialStateRecord = mergeDeep(initialRootState, stateOverrides);
|
const initialStateRecord = mergeDeep(initialRootState, ...stateOverrides);
|
||||||
const PatchedRootStateRecord = ImmutableRecord(
|
const PatchedRootStateRecord = ImmutableRecord(
|
||||||
initialStateRecord,
|
initialStateRecord,
|
||||||
'RootState',
|
'RootState',
|
||||||
|
|||||||
@ -65,6 +65,9 @@ export const searchReducer = createReducer(initialState, (builder) => {
|
|||||||
(state, action) => {
|
(state, action) => {
|
||||||
state.type = action.meta.arg.type;
|
state.type = action.meta.arg.type;
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
|
if (action.type === submitSearch.pending.type) {
|
||||||
|
state.results = undefined;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -124,6 +124,8 @@ const initialState = ImmutableMap({
|
|||||||
'explore/links': false,
|
'explore/links': false,
|
||||||
'explore/statuses': false,
|
'explore/statuses': false,
|
||||||
'explore/tags': false,
|
'explore/tags': false,
|
||||||
|
'notifications/remove_quote_hint': false,
|
||||||
|
'quote/quiet_post_hint': false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
&.button-tertiary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 6px 17px;
|
padding: 6px 17px;
|
||||||
@ -436,8 +471,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body > [data-popper-placement] {
|
[data-popper-placement] {
|
||||||
z-index: 3;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invisible {
|
.invisible {
|
||||||
@ -2904,10 +2939,26 @@ a.account__display-name {
|
|||||||
color: $error-value-color;
|
color: $error-value-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--highlighted {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-subtitle {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 14px; // glitch: reduced padding
|
padding: 6px 14px; // glitch: reduced padding
|
||||||
border: 0;
|
border: 0;
|
||||||
@ -2916,9 +2967,6 @@ a.account__display-name {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
@ -2938,30 +2986,8 @@ a.account__display-name {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reblog-button {
|
.reblog-menu-item {
|
||||||
&__item {
|
|
||||||
max-width: 360px;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-account {
|
.inline-account {
|
||||||
@ -5606,7 +5632,6 @@ a.status-card {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
width: 300px;
|
|
||||||
|
|
||||||
&.top {
|
&.top {
|
||||||
transform-origin: 50% 100%;
|
transform-origin: 50% 100%;
|
||||||
@ -5818,6 +5843,7 @@ a.status-card {
|
|||||||
|
|
||||||
.language-dropdown {
|
.language-dropdown {
|
||||||
&__dropdown {
|
&__dropdown {
|
||||||
|
width: 300px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
.emoji-mart-search {
|
.emoji-mart-search {
|
||||||
@ -5905,26 +5931,23 @@ a.status-card {
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&.disabled {
|
||||||
cursor: pointer;
|
opacity: 0.6;
|
||||||
display: block;
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
> span {
|
&__label {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
background: var(--dropdown-background-color);
|
background: var(--input-background-color);
|
||||||
border: 1px solid var(--dropdown-border-color);
|
border: 1px solid var(--background-border-color);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@ -5937,6 +5960,13 @@ a.status-card {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
margin-inline: auto -4px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
&__helper {
|
&__helper {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
@ -7360,7 +7390,8 @@ img.modal-warning {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-gallery__alt__popover {
|
.info-tooltip {
|
||||||
|
color: $white;
|
||||||
background: color.change($black, $alpha: 0.65);
|
background: color.change($black, $alpha: 0.65);
|
||||||
backdrop-filter: $backdrop-blur-filter;
|
backdrop-filter: $backdrop-blur-filter;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@ -7372,20 +7403,36 @@ img.modal-warning {
|
|||||||
max-height: 30em;
|
max-height: 30em;
|
||||||
overflow-y: auto;
|
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 {
|
h4 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $white;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
color: color.change($white, $alpha: 0.85);
|
opacity: 0.85;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-block-start: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-list {
|
.attachment-list {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ body {
|
|||||||
--dropdown-border-color: hsl(240deg, 25%, 88%);
|
--dropdown-border-color: hsl(240deg, 25%, 88%);
|
||||||
--dropdown-background-color: #fff;
|
--dropdown-background-color: #fff;
|
||||||
--modal-border-color: hsl(240deg, 25%, 88%);
|
--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-border-color: hsl(240deg, 25%, 88%);
|
||||||
--background-color: #fff;
|
--background-color: #fff;
|
||||||
--background-color-tint: rgba(255, 255, 255, 80%);
|
--background-color-tint: rgba(255, 255, 255, 80%);
|
||||||
|
|||||||
16
app/javascript/flavours/glitch/utils/types.ts
Normal file
16
app/javascript/flavours/glitch/utils/types.ts
Normal 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>>;
|
||||||
@ -16,6 +16,7 @@ import type { Status } from '../models/status';
|
|||||||
|
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { focusCompose } from './compose';
|
import { focusCompose } from './compose';
|
||||||
|
import { openModal } from './modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
quoteErrorUpload: {
|
quoteErrorUpload: {
|
||||||
@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk(
|
|||||||
|
|
||||||
export const quoteComposeByStatus = createAppThunk(
|
export const quoteComposeByStatus = createAppThunk(
|
||||||
(status: Status, { dispatch, getState }) => {
|
(status: Status, { dispatch, getState }) => {
|
||||||
const composeState = getState().compose;
|
const state = getState();
|
||||||
|
const composeState = state.compose;
|
||||||
const mediaAttachments = composeState.get('media_attachments');
|
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')) {
|
if (composeState.get('poll')) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
dispatch(showAlert({ message: messages.quoteErrorPoll }));
|
||||||
@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk(
|
|||||||
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
status.getIn(['quote_approval', 'current_user']) !== 'manual'
|
||||||
) {
|
) {
|
||||||
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
dispatch(showAlert({ message: messages.quoteErrorUnauthorized }));
|
||||||
|
} else if (
|
||||||
|
status.get('visibility') === 'unlisted' &&
|
||||||
|
!wasQuietPostHintModalDismissed
|
||||||
|
) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM_QUIET_QUOTE',
|
||||||
|
modalProps: { status },
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(quoteCompose(status));
|
dispatch(quoteCompose(status));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,15 @@ export function normalizeFilterResult(result) {
|
|||||||
return normalResult;
|
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) {
|
export function normalizeStatus(status, normalOldStatus) {
|
||||||
const normalStatus = { ...status };
|
const normalStatus = { ...status };
|
||||||
|
|
||||||
@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) {
|
|||||||
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
|
||||||
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
|
||||||
|
|
||||||
|
// 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://'))) {
|
if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) {
|
||||||
normalStatus.url = null;
|
normalStatus.url = null;
|
||||||
}
|
}
|
||||||
@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) {
|
|||||||
spoiler_text: translation.spoiler_text,
|
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;
|
return normalTranslation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,9 @@ import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
|
|||||||
const offset = [0, 4] as OffsetValue;
|
const offset = [0, 4] as OffsetValue;
|
||||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||||
|
|
||||||
export const AltTextBadge: React.FC<{
|
export const AltTextBadge: React.FC<{ description: string }> = ({
|
||||||
description: string;
|
description,
|
||||||
}> = ({ description }) => {
|
}) => {
|
||||||
const accessibilityId = useId();
|
const accessibilityId = useId();
|
||||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{
|
|||||||
{({ props }) => (
|
{({ props }) => (
|
||||||
<div {...props} className='hover-card-controller'>
|
<div {...props} className='hover-card-controller'>
|
||||||
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
||||||
className='media-gallery__alt__popover dropdown-animation'
|
className='info-tooltip dropdown-animation'
|
||||||
role='region'
|
role='region'
|
||||||
id={accessibilityId}
|
id={accessibilityId}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const meta = {
|
|||||||
component: Button,
|
component: Button,
|
||||||
args: {
|
args: {
|
||||||
secondary: false,
|
secondary: false,
|
||||||
|
plain: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
dangerous: false,
|
dangerous: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@ -57,6 +58,14 @@ export const Secondary: Story = {
|
|||||||
play: buttonTest,
|
play: buttonTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Plain: Story = {
|
||||||
|
args: {
|
||||||
|
plain: true,
|
||||||
|
children: 'Plain button',
|
||||||
|
},
|
||||||
|
play: buttonTest,
|
||||||
|
};
|
||||||
|
|
||||||
export const Compact: Story = {
|
export const Compact: Story = {
|
||||||
args: {
|
args: {
|
||||||
compact: true,
|
compact: true,
|
||||||
@ -101,6 +110,14 @@ export const SecondaryDisabled: Story = {
|
|||||||
play: disabledButtonTest,
|
play: disabledButtonTest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PlainDisabled: Story = {
|
||||||
|
args: {
|
||||||
|
...Plain.args,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
play: disabledButtonTest,
|
||||||
|
};
|
||||||
|
|
||||||
const loadingButtonTest: Story['play'] = async ({
|
const loadingButtonTest: Story['play'] = async ({
|
||||||
args,
|
args,
|
||||||
canvas,
|
canvas,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ interface BaseProps
|
|||||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
secondary?: boolean;
|
secondary?: boolean;
|
||||||
|
plain?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@ -35,6 +36,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
disabled,
|
disabled,
|
||||||
block,
|
block,
|
||||||
secondary,
|
secondary,
|
||||||
|
plain,
|
||||||
compact,
|
compact,
|
||||||
dangerous,
|
dangerous,
|
||||||
loading,
|
loading,
|
||||||
@ -62,6 +64,7 @@ export const Button: React.FC<Props> = ({
|
|||||||
<button
|
<button
|
||||||
className={classNames('button', className, {
|
className={classNames('button', className, {
|
||||||
'button-secondary': secondary,
|
'button-secondary': secondary,
|
||||||
|
'button--plain': plain,
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
'button--block': block,
|
'button--block': block,
|
||||||
'button--dangerous': dangerous,
|
'button--dangerous': dangerous,
|
||||||
|
|||||||
@ -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 type { PropsWithChildren } from 'react';
|
||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
|
||||||
@ -23,31 +18,48 @@ interface Props {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
export function useDismissableBannerState({ id }: Props) {
|
||||||
id,
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
children,
|
const dismissed: boolean = useAppSelector((state) =>
|
||||||
}) => {
|
/* eslint-disable-next-line */
|
||||||
const dismissed = useAppSelector((state) =>
|
|
||||||
state.settings.getIn(['dismissed_banners', id], false),
|
state.settings.getIn(['dismissed_banners', id], false),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isVisible, setIsVisible] = useState(
|
||||||
|
!bannerSettings.get(id) && !dismissed,
|
||||||
|
);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [visible, setVisible] = useState(!bannerSettings.get(id) && !dismissed);
|
const dismiss = useCallback(() => {
|
||||||
const intl = useIntl();
|
setIsVisible(false);
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
setVisible(false);
|
|
||||||
bannerSettings.set(id, true);
|
bannerSettings.set(id, true);
|
||||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
dispatch(changeSetting(['dismissed_banners', id], true));
|
||||||
}, [id, dispatch]);
|
}, [id, dispatch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible && !dismissed) {
|
// Store legacy localStorage setting on server
|
||||||
|
if (!isVisible && !dismissed) {
|
||||||
dispatch(changeSetting(['dismissed_banners', id], true));
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +70,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
icon='times'
|
icon='times'
|
||||||
iconComponent={CloseIcon}
|
iconComponent={CloseIcon}
|
||||||
title={intl.formatMessage(messages.dismiss)}
|
title={intl.formatMessage(messages.dismiss)}
|
||||||
onClick={handleDismiss}
|
onClick={dismiss}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -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} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
122
app/javascript/mastodon/components/display_name/index.tsx
Normal file
122
app/javascript/mastodon/components/display_name/index.tsx
Normal 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'>
|
||||||
|
|
||||||
|
<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'> {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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,22 +1,28 @@
|
|||||||
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
import { useCallback, useId, useMemo, useRef, useState } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, FC } 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 type { MessageDescriptor } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import Overlay from 'react-overlays/Overlay';
|
import Overlay from 'react-overlays/Overlay';
|
||||||
|
|
||||||
|
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
|
||||||
|
|
||||||
import type { SelectItem } from '../dropdown_selector';
|
import type { SelectItem } from '../dropdown_selector';
|
||||||
import { DropdownSelector } from '../dropdown_selector';
|
import { DropdownSelector } from '../dropdown_selector';
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
|
||||||
|
import { matchWidth } from './utils';
|
||||||
|
|
||||||
interface DropdownProps {
|
interface DropdownProps {
|
||||||
title: string;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
items: SelectItem[];
|
items: SelectItem[];
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
current: string;
|
current: string;
|
||||||
|
labelId: string;
|
||||||
|
descriptionId?: string;
|
||||||
emptyText?: MessageDescriptor;
|
emptyText?: MessageDescriptor;
|
||||||
classPrefix: string;
|
classPrefix: string;
|
||||||
}
|
}
|
||||||
@ -24,39 +30,59 @@ interface DropdownProps {
|
|||||||
export const Dropdown: FC<
|
export const Dropdown: FC<
|
||||||
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
DropdownProps & Omit<ComponentPropsWithoutRef<'button'>, keyof DropdownProps>
|
||||||
> = ({
|
> = ({
|
||||||
title,
|
|
||||||
disabled,
|
disabled,
|
||||||
items,
|
items,
|
||||||
current,
|
current,
|
||||||
onChange,
|
onChange,
|
||||||
|
labelId,
|
||||||
|
descriptionId,
|
||||||
classPrefix,
|
classPrefix,
|
||||||
className,
|
className,
|
||||||
|
id,
|
||||||
...buttonProps
|
...buttonProps
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
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 [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
setOpen((prevOpen) => !prevOpen);
|
setOpen((prevOpen) => {
|
||||||
|
buttonRef.current?.focus();
|
||||||
|
return !prevOpen;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [disabled]);
|
}, [disabled]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
buttonRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const currentText = useMemo(
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
title={title}
|
id={buttonId}
|
||||||
|
aria-labelledby={`${labelId} ${buttonId}`}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls={accessibilityId}
|
aria-controls={listboxId}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@ -69,23 +95,24 @@ export const Dropdown: FC<
|
|||||||
)}
|
)}
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
{currentText ?? (
|
{currentText}
|
||||||
<FormattedMessage
|
<Icon
|
||||||
id='dropdown.empty'
|
id='unfold-icon'
|
||||||
defaultMessage='Select an option'
|
icon={UnfoldMoreIcon}
|
||||||
|
className={`${classPrefix}__icon`}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Overlay
|
<Overlay
|
||||||
show={open}
|
show={open}
|
||||||
offset={[0, 4]}
|
offset={[0, 0]}
|
||||||
placement='bottom-start'
|
placement='bottom-start'
|
||||||
onHide={handleClose}
|
onHide={handleClose}
|
||||||
flip
|
flip
|
||||||
target={buttonRef.current}
|
target={buttonRef.current}
|
||||||
popperConfig={{
|
popperConfig={{
|
||||||
strategy: 'fixed',
|
strategy: 'fixed',
|
||||||
|
modifiers: [matchWidth],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{({ props, placement }) => (
|
{({ props, placement }) => (
|
||||||
@ -96,7 +123,7 @@ export const Dropdown: FC<
|
|||||||
`${classPrefix}__dropdown`,
|
`${classPrefix}__dropdown`,
|
||||||
placement,
|
placement,
|
||||||
)}
|
)}
|
||||||
id={accessibilityId}
|
id={listboxId}
|
||||||
>
|
>
|
||||||
<DropdownSelector
|
<DropdownSelector
|
||||||
items={items}
|
items={items}
|
||||||
|
|||||||
17
app/javascript/mastodon/components/dropdown/utils.ts
Normal file
17
app/javascript/mastodon/components/dropdown/utils.ts
Normal 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`;
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -36,6 +36,7 @@ import {
|
|||||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { Icon } from './icon';
|
||||||
import type { IconProp } from './icon';
|
import type { IconProp } from './icon';
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from './icon_button';
|
||||||
|
|
||||||
@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
|
|||||||
onItemClick?: ItemClickFn<Item>;
|
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,>({
|
export const DropdownMenu = <Item = MenuItem,>({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
@ -200,7 +222,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
@ -211,8 +233,9 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
@ -227,7 +250,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -239,7 +262,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -247,6 +270,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames('dropdown-menu__item', {
|
className={classNames('dropdown-menu__item', {
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
key={`${text}-${i}`}
|
||||||
|
|||||||
@ -39,24 +39,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const nodeRef = useRef<HTMLUListElement>(null);
|
const listRef = useRef<HTMLUListElement>(null);
|
||||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||||
const [currentValue, setCurrentValue] = useState(value);
|
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(
|
const handleClick = useCallback(
|
||||||
(
|
(
|
||||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||||
@ -88,30 +74,30 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
break;
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index + 1] ??
|
listRef.current?.children[index + 1] ??
|
||||||
nodeRef.current?.firstElementChild;
|
listRef.current?.firstElementChild;
|
||||||
break;
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index - 1] ??
|
listRef.current?.children[index - 1] ??
|
||||||
nodeRef.current?.lastElementChild;
|
listRef.current?.lastElementChild;
|
||||||
break;
|
break;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index - 1] ??
|
listRef.current?.children[index - 1] ??
|
||||||
nodeRef.current?.lastElementChild;
|
listRef.current?.lastElementChild;
|
||||||
} else {
|
} else {
|
||||||
element =
|
element =
|
||||||
nodeRef.current?.children[index + 1] ??
|
listRef.current?.children[index + 1] ??
|
||||||
nodeRef.current?.firstElementChild;
|
listRef.current?.firstElementChild;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
element = nodeRef.current?.firstElementChild;
|
element = listRef.current?.firstElementChild;
|
||||||
break;
|
break;
|
||||||
case 'End':
|
case 'End':
|
||||||
element = nodeRef.current?.lastElementChild;
|
element = listRef.current?.lastElementChild;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,12 +109,24 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
[items, onClose, handleClick, setCurrentValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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('click', handleDocumentClick, { capture: true });
|
||||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||||
|
|
||||||
focusedItemRef.current?.focus({ preventScroll: true });
|
focusedItemRef.current?.focus({ preventScroll: true });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -141,10 +139,10 @@ export const DropdownSelector: React.FC<Props> = ({
|
|||||||
listenerOptions,
|
listenerOptions,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [handleDocumentClick]);
|
}, [onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul style={style} role='listbox' ref={nodeRef}>
|
<ul style={style} role='listbox' ref={listRef}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li
|
<li
|
||||||
role='option'
|
role='option'
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||||
import { statusFactoryState } from '@/testing/factories';
|
import { statusFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
|
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||||
|
|
||||||
interface StoryProps {
|
interface StoryProps {
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
@ -13,7 +13,7 @@ interface StoryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Status/ReblogButton',
|
title: 'Components/Status/BoostButton',
|
||||||
args: {
|
args: {
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
quoteAllowed: true,
|
quoteAllowed: true,
|
||||||
@ -38,7 +38,7 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<StatusReblogButton
|
<StatusBoostButton
|
||||||
status={argsToStatus(args)}
|
status={argsToStatus(args)}
|
||||||
counters={args.reblogCount > 0}
|
counters={args.reblogCount > 0}
|
||||||
/>
|
/>
|
||||||
253
app/javascript/mastodon/components/status/boost_button.tsx
Normal file
253
app/javascript/mastodon/components/status/boost_button.tsx
Normal 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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
app/javascript/mastodon/components/status/boost_button_utils.ts
Normal file
161
app/javascript/mastodon/components/status/boost_button_utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -20,11 +20,12 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
|
|||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
|
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { me } from '../initial_state';
|
import { me } from '../../initial_state';
|
||||||
|
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
import { isFeatureEnabled } from '../utils/environment';
|
import { isFeatureEnabled } from '../../utils/environment';
|
||||||
import { ReblogButton } from './status/reblog_button';
|
import { BoostButton } from '../status/boost_button';
|
||||||
|
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
status: ImmutablePropTypes.map.isRequired,
|
status: ImmutablePropTypes.map.isRequired,
|
||||||
relationship: ImmutablePropTypes.record,
|
relationship: ImmutablePropTypes.record,
|
||||||
quotedAccountId: PropTypes.string,
|
quotedAccountId: PropTypes.string,
|
||||||
|
contextType: PropTypes.string,
|
||||||
onReply: PropTypes.func,
|
onReply: PropTypes.func,
|
||||||
onFavourite: PropTypes.func,
|
onFavourite: PropTypes.func,
|
||||||
onDelete: PropTypes.func,
|
onDelete: PropTypes.func,
|
||||||
@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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 { signedIn, permissions } = this.props.identity;
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
const account = status.get('account');
|
const account = status.get('account');
|
||||||
const writtenByMe = status.getIn(['account', 'id']) === me;
|
const writtenByMe = status.getIn(['account', 'id']) === me;
|
||||||
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']);
|
||||||
|
const isQuotingMe = quotedAccountId === me;
|
||||||
|
|
||||||
let menu = [];
|
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({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick });
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
||||||
if (quotedAccountId === me) {
|
if (isQuotingMe) {
|
||||||
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,13 +364,15 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
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 isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||||
|
|
||||||
|
const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='status__action-bar'>
|
<div className='status__action-bar'>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
<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>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<ReblogButton status={status} counters={withCounters} />
|
<BoostButton status={status} counters={withCounters} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<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} />
|
<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,7 +380,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
<div className='status__action-bar__button-wrapper'>
|
<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} />
|
<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>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<RemoveQuoteHint className='status__action-bar__button-wrapper' canShowHint={shouldShowQuoteRemovalHint}>
|
||||||
|
{(dismissQuoteHint) => (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
scrollKey={scrollKey}
|
scrollKey={scrollKey}
|
||||||
status={status}
|
status={status}
|
||||||
@ -384,8 +390,13 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
iconComponent={MoreHorizIcon}
|
iconComponent={MoreHorizIcon}
|
||||||
direction='right'
|
direction='right'
|
||||||
title={intl.formatMessage(messages.more)}
|
title={intl.formatMessage(messages.more)}
|
||||||
|
onOpen={() => {
|
||||||
|
dismissQuoteHint();
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</RemoveQuoteHint>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -138,16 +138,6 @@ class StatusContent extends PureComponent {
|
|||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
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 }) => {
|
handleMouseEnter = ({ currentTarget }) => {
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export const messages = defineMessages({
|
|||||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||||
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' },
|
||||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
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_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||||
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' },
|
||||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||||
|
|||||||
@ -79,10 +79,12 @@ const visibilityOptions = {
|
|||||||
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
const PrivacyModalButton: FC<PrivacyDropdownProps> = ({ disabled = false }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { visibility, quotePolicy } = useAppSelector((state) => ({
|
const quotePolicy = useAppSelector(
|
||||||
visibility: state.compose.get('privacy') as StatusVisibility,
|
(state) => state.compose.get('quote_policy') as ApiQuotePolicy,
|
||||||
quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy,
|
);
|
||||||
}));
|
const visibility = useAppSelector(
|
||||||
|
(state) => state.compose.get('privacy') as StatusVisibility,
|
||||||
|
);
|
||||||
|
|
||||||
const { icon, iconComponent } = useMemo(() => {
|
const { icon, iconComponent } = useMemo(() => {
|
||||||
const option = visibilityOptions[visibility];
|
const option = visibilityOptions[visibility];
|
||||||
|
|||||||
@ -12,16 +12,21 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
|||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
as?: Element;
|
||||||
|
shallow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModernEmojiHTML = <Element extends ElementType>({
|
export const ModernEmojiHTML = ({
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
htmlString,
|
htmlString,
|
||||||
as: asElement, // Rename for syntax highlighting
|
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||||
|
shallow,
|
||||||
...props
|
...props
|
||||||
}: EmojiHTMLProps<Element>) => {
|
}: EmojiHTMLProps<ElementType>) => {
|
||||||
const Wrapper = asElement ?? 'div';
|
const emojifiedHtml = useEmojify({
|
||||||
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
text: htmlString,
|
||||||
|
extraEmojis,
|
||||||
|
deep: !shallow,
|
||||||
|
});
|
||||||
|
|
||||||
if (emojifiedHtml === null) {
|
if (emojifiedHtml === null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
|
import { emojifyElement, emojifyText } from './render';
|
||||||
import type {
|
import type {
|
||||||
CustomEmojiMapArg,
|
CustomEmojiMapArg,
|
||||||
EmojiAppState,
|
EmojiAppState,
|
||||||
@ -15,7 +16,17 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { stringHasAnyEmoji } from './utils';
|
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 [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
const appState = useEmojiAppState();
|
const appState = useEmojiAppState();
|
||||||
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
|||||||
|
|
||||||
const emojify = useCallback(
|
const emojify = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
|
let result: string | null = null;
|
||||||
|
if (deep) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = input;
|
wrapper.innerHTML = input;
|
||||||
const { emojifyElement } = await import('./render');
|
if (await emojifyElement(wrapper, appState, extra)) {
|
||||||
const result = await emojifyElement(wrapper, appState, extra);
|
result = wrapper.innerHTML;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await emojifyText(text, appState, extra);
|
||||||
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
setEmojifiedText(result.innerHTML);
|
setEmojifiedText(result);
|
||||||
} else {
|
} else {
|
||||||
setEmojifiedText(input);
|
setEmojifiedText(input);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appState, extra],
|
[appState, deep, extra, text],
|
||||||
);
|
);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {
|
||||||
|
|||||||
@ -143,7 +143,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='app-form__toggle__toggle'>
|
<div className='app-form__toggle__toggle'>
|
||||||
<div>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
@ -153,7 +152,6 @@ export const SelectWithLabel: React.FC<PropsWithChildren<Props>> = ({
|
|||||||
options={options}
|
options={options}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const messages = defineMessages({
|
|||||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||||
reblog_private: {
|
reblog_private: {
|
||||||
id: 'status.reblog_private',
|
id: 'status.reblog_private',
|
||||||
defaultMessage: 'Boost with original visibility',
|
defaultMessage: 'Share again with your followers',
|
||||||
},
|
},
|
||||||
cancel_reblog_private: {
|
cancel_reblog_private: {
|
||||||
id: 'status.cancel_reblog_private',
|
id: 'status.cancel_reblog_private',
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { IconButton } from '../../../components/icon_button';
|
|||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { me } from '../../../initial_state';
|
import { me } from '../../../initial_state';
|
||||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
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({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
@ -310,7 +310,7 @@ class ActionBar extends PureComponent {
|
|||||||
<div className='detailed-status__action-bar'>
|
<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'><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'>
|
<div className='detailed-status__button'>
|
||||||
<ReblogButton status={status} />
|
<BoostButton status={status} />
|
||||||
</div>
|
</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='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>
|
<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>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DropdownMenuItemContent } from 'mastodon/components/dropdown_menu';
|
||||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
import {
|
import {
|
||||||
isActionItem,
|
isActionItem,
|
||||||
@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{
|
|||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
if (isActionItem(option)) {
|
if (isActionItem(option)) {
|
||||||
element = (
|
element = (
|
||||||
<button onClick={onClick} data-index={i}>
|
<button onClick={onClick} data-index={i} disabled={disabled}>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
element = (
|
element = (
|
||||||
<Link to={option.to} onClick={onClick} data-index={i}>
|
<Link to={option.to} onClick={onClick} data-index={i}>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames({
|
className={classNames('dropdown-menu__item', {
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
key={`${text}-${i}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -43,10 +43,6 @@ export const ConfirmationModal: React.FC<
|
|||||||
onSecondary?.();
|
onSecondary?.();
|
||||||
}, [onClose, onSecondary]);
|
}, [onClose, onSecondary]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal safety-action-modal'>
|
<div className='modal-root__modal safety-action-modal'>
|
||||||
<div className='safety-action-modal__top'>
|
<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__bottom'>
|
||||||
<div className='safety-action-modal__actions'>
|
<div className='safety-action-modal__actions'>
|
||||||
<button onClick={handleCancel} className='link-button'>
|
<button onClick={onClose} className='link-button'>
|
||||||
{cancel ?? (
|
{cancel ?? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='confirmation_modal.cancel'
|
id='confirmation_modal.cancel'
|
||||||
|
|||||||
@ -11,3 +11,4 @@ export { ConfirmLogOutModal } from './log_out';
|
|||||||
export { ConfirmFollowToListModal } from './follow_to_list';
|
export { ConfirmFollowToListModal } from './follow_to_list';
|
||||||
export { ConfirmMissingAltTextModal } from './missing_alt_text';
|
export { ConfirmMissingAltTextModal } from './missing_alt_text';
|
||||||
export { ConfirmRevokeQuoteModal } from './revoke_quote';
|
export { ConfirmRevokeQuoteModal } from './revoke_quote';
|
||||||
|
export { QuietPostQuoteInfoModal } from './quiet_post_quote_info';
|
||||||
|
|||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -201,8 +201,6 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
preview={image.get('preview_url')}
|
preview={image.get('preview_url')}
|
||||||
blurhash={image.get('blurhash')}
|
blurhash={image.get('blurhash')}
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={image.get('width')}
|
|
||||||
height={image.get('height')}
|
|
||||||
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
|
||||||
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
|
aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
|
||||||
startTime={currentTime || 0}
|
startTime={currentTime || 0}
|
||||||
@ -219,8 +217,6 @@ class MediaModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<GIFV
|
<GIFV
|
||||||
src={image.get('url')}
|
src={image.get('url')}
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
key={image.get('url')}
|
key={image.get('url')}
|
||||||
alt={description}
|
alt={description}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
ConfirmFollowToListModal,
|
ConfirmFollowToListModal,
|
||||||
ConfirmMissingAltTextModal,
|
ConfirmMissingAltTextModal,
|
||||||
ConfirmRevokeQuoteModal,
|
ConfirmRevokeQuoteModal,
|
||||||
|
QuietPostQuoteInfoModal,
|
||||||
} from './confirmation_modals';
|
} from './confirmation_modals';
|
||||||
import { ImageModal } from './image_modal';
|
import { ImageModal } from './image_modal';
|
||||||
import MediaModal from './media_modal';
|
import MediaModal from './media_modal';
|
||||||
@ -62,6 +63,7 @@ export const MODAL_COMPONENTS = {
|
|||||||
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
||||||
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
'CONFIRM_MISSING_ALT_TEXT': () => Promise.resolve({ default: ConfirmMissingAltTextModal }),
|
||||||
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
'CONFIRM_REVOKE_QUOTE': () => Promise.resolve({ default: ConfirmRevokeQuoteModal }),
|
||||||
|
'CONFIRM_QUIET_QUOTE': () => Promise.resolve({ default: QuietPostQuoteInfoModal }),
|
||||||
'MUTE': MuteModal,
|
'MUTE': MuteModal,
|
||||||
'BLOCK': BlockModal,
|
'BLOCK': BlockModal,
|
||||||
'DOMAIN_BLOCK': DomainBlockModal,
|
'DOMAIN_BLOCK': DomainBlockModal,
|
||||||
|
|||||||
@ -163,7 +163,6 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
value: 'unlisted',
|
value: 'unlisted',
|
||||||
text: intl.formatMessage(privacyMessages.unlisted_short),
|
text: intl.formatMessage(privacyMessages.unlisted_short),
|
||||||
meta: intl.formatMessage(privacyMessages.unlisted_long),
|
meta: intl.formatMessage(privacyMessages.unlisted_long),
|
||||||
extra: intl.formatMessage(privacyMessages.unlisted_extra),
|
|
||||||
icon: 'unlock',
|
icon: 'unlock',
|
||||||
iconComponent: QuietTimeIcon,
|
iconComponent: QuietTimeIcon,
|
||||||
},
|
},
|
||||||
@ -199,8 +198,11 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
onClose();
|
onClose();
|
||||||
}, [onChange, onClose, visibility, quotePolicy]);
|
}, [onChange, onClose, visibility, quotePolicy]);
|
||||||
|
|
||||||
const privacyDropdownId = useId();
|
const uniqueId = useId();
|
||||||
const quoteDropdownId = useId();
|
const visibilityLabelId = `${uniqueId}-visibility-label`;
|
||||||
|
const visibilityDescriptionId = `${uniqueId}-visibility-desc`;
|
||||||
|
const quoteLabelId = `${uniqueId}-quote-label`;
|
||||||
|
const quoteDescriptionId = `${uniqueId}-quote-desc`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal dialog-modal visibility-modal'>
|
<div className='modal-root__modal dialog-modal visibility-modal'>
|
||||||
@ -235,28 +237,36 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='dialog-modal__content__form'>
|
<div className='dialog-modal__content__form'>
|
||||||
<label
|
<div
|
||||||
htmlFor={privacyDropdownId}
|
className={classNames('visibility-dropdown', {
|
||||||
className={classNames('visibility-dropdown__label', {
|
|
||||||
disabled: disableVisibility,
|
disabled: disableVisibility,
|
||||||
})}
|
})}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label
|
||||||
|
className='visibility-dropdown__label'
|
||||||
|
id={visibilityLabelId}
|
||||||
>
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.privacy_label'
|
id='visibility_modal.privacy_label'
|
||||||
defaultMessage='Visibility'
|
defaultMessage='Visibility'
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={visibilityItems}
|
items={visibilityItems}
|
||||||
classPrefix='visibility-dropdown'
|
|
||||||
current={visibility}
|
current={visibility}
|
||||||
onChange={handleVisibilityChange}
|
onChange={handleVisibilityChange}
|
||||||
title={intl.formatMessage(privacyMessages.change_privacy)}
|
labelId={visibilityLabelId}
|
||||||
|
descriptionId={visibilityDescriptionId}
|
||||||
|
classPrefix='visibility-dropdown'
|
||||||
disabled={disableVisibility}
|
disabled={disableVisibility}
|
||||||
id={privacyDropdownId}
|
|
||||||
/>
|
/>
|
||||||
{!!statusId && (
|
{!!statusId && (
|
||||||
<p className='visibility-dropdown__helper'>
|
<p
|
||||||
|
className='visibility-dropdown__helper'
|
||||||
|
id='visibilityDescriptionId'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.privacy_editing'
|
id='visibility_modal.helper.privacy_editing'
|
||||||
defaultMessage="Visibility can't be changed after a post is published."
|
defaultMessage="Visibility can't be changed after a post is published."
|
||||||
@ -264,37 +274,47 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!statusId && disablePublicVisibilities && (
|
{!statusId && disablePublicVisibilities && (
|
||||||
<p className='visibility-dropdown__helper'>
|
<p
|
||||||
|
className='visibility-dropdown__helper'
|
||||||
|
id='visibilityDescriptionId'
|
||||||
|
>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.privacy_private_self_quote'
|
id='visibility_modal.helper.privacy_private_self_quote'
|
||||||
defaultMessage='Self-quotes of private posts cannot be made public.'
|
defaultMessage='Self-quotes of private posts cannot be made public.'
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<label
|
<div
|
||||||
htmlFor={quoteDropdownId}
|
className={classNames('visibility-dropdown', {
|
||||||
className={classNames('visibility-dropdown__label', {
|
|
||||||
disabled: disableQuotePolicy,
|
disabled: disableQuotePolicy,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||||
|
<label className='visibility-dropdown__label' id={quoteLabelId}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.quote_label'
|
id='visibility_modal.quote_label'
|
||||||
defaultMessage='Who can quote'
|
defaultMessage='Who can quote'
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={quoteItems}
|
items={quoteItems}
|
||||||
onChange={handleQuotePolicyChange}
|
|
||||||
classPrefix='visibility-dropdown'
|
|
||||||
current={disableQuotePolicy ? 'nobody' : quotePolicy}
|
current={disableQuotePolicy ? 'nobody' : quotePolicy}
|
||||||
title={intl.formatMessage(messages.buttonTitle)}
|
onChange={handleQuotePolicyChange}
|
||||||
|
labelId={quoteLabelId}
|
||||||
|
descriptionId={quoteDescriptionId}
|
||||||
|
classPrefix='visibility-dropdown'
|
||||||
disabled={disableQuotePolicy}
|
disabled={disableQuotePolicy}
|
||||||
id={quoteDropdownId}
|
|
||||||
/>
|
/>
|
||||||
<QuotePolicyHelper policy={quotePolicy} visibility={visibility} />
|
<QuotePolicyHelper
|
||||||
</label>
|
policy={quotePolicy}
|
||||||
|
visibility={visibility}
|
||||||
|
className='visibility-dropdown__helper'
|
||||||
|
id={quoteDescriptionId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='dialog-modal__content__actions'>
|
<div className='dialog-modal__content__actions'>
|
||||||
<Button onClick={onClose} secondary>
|
<Button onClick={onClose} secondary>
|
||||||
@ -317,42 +337,44 @@ export const VisibilityModal: FC<VisibilityModalProps> = forwardRef(
|
|||||||
);
|
);
|
||||||
VisibilityModal.displayName = 'VisibilityModal';
|
VisibilityModal.displayName = 'VisibilityModal';
|
||||||
|
|
||||||
const QuotePolicyHelper: FC<{
|
const QuotePolicyHelper: FC<
|
||||||
|
{
|
||||||
policy: ApiQuotePolicy;
|
policy: ApiQuotePolicy;
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
}> = ({ policy, visibility }) => {
|
} & React.ComponentPropsWithoutRef<'p'>
|
||||||
|
> = ({ policy, visibility, ...otherProps }) => {
|
||||||
|
let hintText: React.ReactElement | undefined;
|
||||||
|
|
||||||
if (visibility === 'unlisted' && policy !== 'nobody') {
|
if (visibility === 'unlisted' && policy !== 'nobody') {
|
||||||
return (
|
hintText = (
|
||||||
<p className='visibility-dropdown__helper'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.unlisted_quoting'
|
id='visibility_modal.helper.unlisted_quoting'
|
||||||
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
|
defaultMessage='When people quote you, their post will also be hidden from trending timelines.'
|
||||||
/>
|
/>
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility === 'private') {
|
if (visibility === 'private') {
|
||||||
return (
|
hintText = (
|
||||||
<p className='visibility-dropdown__helper'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.private_quoting'
|
id='visibility_modal.helper.private_quoting'
|
||||||
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
|
defaultMessage="Follower-only posts authored on Mastodon can't be quoted by others."
|
||||||
/>
|
/>
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility === 'direct') {
|
if (visibility === 'direct') {
|
||||||
return (
|
hintText = (
|
||||||
<p className='visibility-dropdown__helper'>
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='visibility_modal.helper.direct_quoting'
|
id='visibility_modal.helper.direct_quoting'
|
||||||
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
|
defaultMessage="Private mentions authored on Mastodon can't be quoted by others."
|
||||||
/>
|
/>
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hintText) {
|
||||||
return null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p {...otherProps}>{hintText}</p>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -92,8 +92,7 @@ const messages = defineMessages({
|
|||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
layout: state.getIn(['meta', 'layout']),
|
layout: state.getIn(['meta', 'layout']),
|
||||||
isComposing: state.getIn(['compose', 'is_composing']),
|
isComposing: state.getIn(['compose', 'is_composing']),
|
||||||
hasComposingText: state.getIn(['compose', 'text']).trim().length !== 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,
|
||||||
hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
|
|
||||||
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']),
|
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,
|
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,
|
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,
|
dispatch: PropTypes.func.isRequired,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
isComposing: PropTypes.bool,
|
isComposing: PropTypes.bool,
|
||||||
hasComposingText: PropTypes.bool,
|
hasComposingContents: PropTypes.bool,
|
||||||
hasMediaAttachments: PropTypes.bool,
|
|
||||||
canUploadMore: PropTypes.bool,
|
canUploadMore: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
layout: PropTypes.string.isRequired,
|
layout: PropTypes.string.isRequired,
|
||||||
@ -257,11 +255,11 @@ class UI extends PureComponent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleBeforeUnload = e => {
|
handleBeforeUnload = e => {
|
||||||
const { intl, dispatch, isComposing, hasComposingText, hasMediaAttachments } = this.props;
|
const { intl, dispatch, isComposing, hasComposingContents } = this.props;
|
||||||
|
|
||||||
dispatch(synchronouslySubmitMarkers());
|
dispatch(synchronouslySubmitMarkers());
|
||||||
|
|
||||||
if (isComposing && (hasComposingText || hasMediaAttachments)) {
|
if (isComposing && hasComposingContents) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Setting returnValue to any string causes confirmation dialog.
|
// Setting returnValue to any string causes confirmation dialog.
|
||||||
// Many browsers no longer display this text to users,
|
// Many browsers no longer display this text to users,
|
||||||
|
|||||||
@ -256,7 +256,6 @@
|
|||||||
"status.edited_x_times": "Edited {count, plural, one {# time} other {# times}}",
|
"status.edited_x_times": "Edited {count, plural, one {# time} other {# times}}",
|
||||||
"status.open": "Brei hierdie plasing uit",
|
"status.open": "Brei hierdie plasing uit",
|
||||||
"status.reblog": "Stuur aan",
|
"status.reblog": "Stuur aan",
|
||||||
"status.reblog_private": "Stuur aan met oorspronklike sigbaarheid",
|
|
||||||
"status.reblogged_by": "Aangestuur deur {name}",
|
"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.reblogs.empty": "Hierdie plasing is nog deur niemand aangestuur nie. As iemand dit aanstuur, sal jy dit hier sien.",
|
||||||
"status.reply": "Reageer",
|
"status.reply": "Reageer",
|
||||||
|
|||||||
@ -442,7 +442,6 @@
|
|||||||
"status.pin": "Fixar",
|
"status.pin": "Fixar",
|
||||||
"status.read_more": "Leyer mas",
|
"status.read_more": "Leyer mas",
|
||||||
"status.reblog": "Retutar",
|
"status.reblog": "Retutar",
|
||||||
"status.reblog_private": "Empentar con l'audiencia orichinal",
|
|
||||||
"status.reblogged_by": "Retutau per {name}",
|
"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.reblogs.empty": "Dengún no ha retutau encara este tut. Quan belún lo faiga, amaneixerá aquí.",
|
||||||
"status.redraft": "Borrar y tornar ta borrador",
|
"status.redraft": "Borrar y tornar ta borrador",
|
||||||
|
|||||||
@ -731,7 +731,6 @@
|
|||||||
"privacy.public.long": "أي شخص على أو خارج ماستدون",
|
"privacy.public.long": "أي شخص على أو خارج ماستدون",
|
||||||
"privacy.public.short": "للعامة",
|
"privacy.public.short": "للعامة",
|
||||||
"privacy.unlisted.additional": "هذا يتصرف بالضبط مثل النشر للعامة، باستثناء أن المنشور لن يظهر في الموجزات الحية أو في الوسوم أو في الإستكشاف، أو في نتائج بحث ماستدون، حتى وإن قمت بتفعيله على مستوى الحساب.",
|
"privacy.unlisted.additional": "هذا يتصرف بالضبط مثل النشر للعامة، باستثناء أن المنشور لن يظهر في الموجزات الحية أو في الوسوم أو في الإستكشاف، أو في نتائج بحث ماستدون، حتى وإن قمت بتفعيله على مستوى الحساب.",
|
||||||
"privacy.unlisted.long": "خوارزميات أقل",
|
|
||||||
"privacy.unlisted.short": "للعامة دون صخب",
|
"privacy.unlisted.short": "للعامة دون صخب",
|
||||||
"privacy_policy.last_updated": "آخر تحديث {date}",
|
"privacy_policy.last_updated": "آخر تحديث {date}",
|
||||||
"privacy_policy.title": "سياسة الخصوصية",
|
"privacy_policy.title": "سياسة الخصوصية",
|
||||||
@ -874,7 +873,6 @@
|
|||||||
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
|
"status.quote_error.filtered": "مُخفي بسبب إحدى إعدادات التصفية خاصتك",
|
||||||
"status.read_more": "اقرأ المزيد",
|
"status.read_more": "اقرأ المزيد",
|
||||||
"status.reblog": "إعادة النشر",
|
"status.reblog": "إعادة النشر",
|
||||||
"status.reblog_private": "إعادة النشر إلى الجمهور الأصلي",
|
|
||||||
"status.reblogged_by": "شارَكَه {name}",
|
"status.reblogged_by": "شارَكَه {name}",
|
||||||
"status.reblogs": "{count, plural, one {إعادة نشر واحدة} two {معاد نشرها مرتان} few {# إعادات نشر} many {# إعادات نشر} other {# إعادة نشر}}",
|
"status.reblogs": "{count, plural, one {إعادة نشر واحدة} two {معاد نشرها مرتان} few {# إعادات نشر} many {# إعادات نشر} other {# إعادة نشر}}",
|
||||||
"status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",
|
"status.reblogs.empty": "لم يقم أي أحد بمشاركة هذا المنشور بعد. عندما يقوم أحدهم بذلك سوف يظهر هنا.",
|
||||||
|
|||||||
@ -872,7 +872,6 @@
|
|||||||
"status.quote_post_author": "@{name} göndərişini sitat gətirdi",
|
"status.quote_post_author": "@{name} göndərişini sitat gətirdi",
|
||||||
"status.read_more": "Daha çoxunu oxu",
|
"status.read_more": "Daha çoxunu oxu",
|
||||||
"status.reblog": "Təkrar paylaş",
|
"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.reblogged_by": "{name} təkrar paylaşdı",
|
||||||
"status.reblogs": "{count, plural, one {təkrar paylaşma} other {təkrar paylaşma}}",
|
"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.",
|
"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",
|
"video.volume_up": "Həcmi artır",
|
||||||
"visibility_modal.button_title": "Görünməni ayarla",
|
"visibility_modal.button_title": "Görünməni ayarla",
|
||||||
"visibility_modal.header": "Görünmə və qarşılıqlı əlaqə",
|
"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.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_followers": "Yalnız izləyicilər",
|
||||||
"visibility_modal.quote_public": "Hər kəs"
|
"visibility_modal.quote_public": "Hər kəs"
|
||||||
|
|||||||
@ -239,6 +239,10 @@
|
|||||||
"confirmations.missing_alt_text.secondary": "Усё адно апублікаваць",
|
"confirmations.missing_alt_text.secondary": "Усё адно апублікаваць",
|
||||||
"confirmations.missing_alt_text.title": "Дадаць альтэрнатыўны тэкст?",
|
"confirmations.missing_alt_text.title": "Дадаць альтэрнатыўны тэкст?",
|
||||||
"confirmations.mute.confirm": "Ігнараваць",
|
"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.confirm": "Выдаліць і перапісаць",
|
||||||
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
|
"confirmations.redraft.message": "Вы ўпэўнены, што хочаце выдаліць допіс і перапісаць яго? Упадабанні і пашырэнні згубяцца, а адказы да арыгінальнага допісу асірацеюць.",
|
||||||
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
|
"confirmations.redraft.title": "Выдаліць і перапісаць допіс?",
|
||||||
@ -745,7 +749,7 @@
|
|||||||
"privacy.quote.disabled": "{visibility}, цытаты адключаныя",
|
"privacy.quote.disabled": "{visibility}, цытаты адключаныя",
|
||||||
"privacy.quote.limited": "{visibility}, абмежаваныя цытаты",
|
"privacy.quote.limited": "{visibility}, абмежаваныя цытаты",
|
||||||
"privacy.unlisted.additional": "Паводзіць сябе гэтак жа, як і публічны, за выключэннем таго, што допіс не будзе адлюстроўвацца ў жывой стужцы, хэштэгах, аглядзе або ў пошуку Mastodon, нават калі Вы ўключылі бачнасць у пошуку ў наладах.",
|
"privacy.unlisted.additional": "Паводзіць сябе гэтак жа, як і публічны, за выключэннем таго, што допіс не будзе адлюстроўвацца ў жывой стужцы, хэштэгах, аглядзе або ў пошуку Mastodon, нават калі Вы ўключылі бачнасць у пошуку ў наладах.",
|
||||||
"privacy.unlisted.long": "Менш фанфар ад алгарытмаў",
|
"privacy.unlisted.long": "Схаваны ад вынікаў пошуку Mastodon, трэндавага і публічных стужак",
|
||||||
"privacy.unlisted.short": "Ціхі публічны",
|
"privacy.unlisted.short": "Ціхі публічны",
|
||||||
"privacy_policy.last_updated": "Адноўлена {date}",
|
"privacy_policy.last_updated": "Адноўлена {date}",
|
||||||
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
"privacy_policy.title": "Палітыка канфідэнцыйнасці",
|
||||||
@ -768,6 +772,9 @@
|
|||||||
"relative_time.minutes": "{number} хв",
|
"relative_time.minutes": "{number} хв",
|
||||||
"relative_time.seconds": "{number} с",
|
"relative_time.seconds": "{number} с",
|
||||||
"relative_time.today": "сёння",
|
"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.attachments": "{count, plural, one {# далучэнне} few {# далучэнні} many {# далучэнняў} other {# далучэння}}",
|
||||||
"reply_indicator.cancel": "Скасаваць",
|
"reply_indicator.cancel": "Скасаваць",
|
||||||
"reply_indicator.poll": "Апытанне",
|
"reply_indicator.poll": "Апытанне",
|
||||||
@ -910,15 +917,15 @@
|
|||||||
"status.read_more": "Чытаць болей",
|
"status.read_more": "Чытаць болей",
|
||||||
"status.reblog": "Пашырыць",
|
"status.reblog": "Пашырыць",
|
||||||
"status.reblog_or_quote": "Пашырыць ці цытаваць",
|
"status.reblog_or_quote": "Пашырыць ці цытаваць",
|
||||||
"status.reblog_private": "Пашырыць з першапачатковай бачнасцю",
|
"status.reblog_private": "Зноў падзяліцца са сваімі падпісчыкамі",
|
||||||
"status.reblogged_by": "Карыстальнік {name} пашырыў",
|
"status.reblogged_by": "{name} пашырыў(-ла)",
|
||||||
"status.reblogs": "{count, plural, one {пашырэнне} few {пашырэнні} many {пашырэнняў} other {пашырэння}}",
|
"status.reblogs": "{count, plural, one {пашырэнне} few {пашырэнні} many {пашырэнняў} other {пашырэння}}",
|
||||||
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
|
"status.reblogs.empty": "Гэты допіс яшчэ ніхто не пашырыў. Калі гэта адбудзецца, гэтых людзей будзе бачна тут.",
|
||||||
"status.redraft": "Выдаліць і перапісаць",
|
"status.redraft": "Выдаліць і перапісаць",
|
||||||
"status.remove_bookmark": "Выдаліць закладку",
|
"status.remove_bookmark": "Выдаліць закладку",
|
||||||
"status.remove_favourite": "Выдаліць з упадабаных",
|
"status.remove_favourite": "Выдаліць з упадабаных",
|
||||||
"status.replied_in_thread": "Адказаў у ланцужку",
|
"status.replied_in_thread": "Адказаў(-ла) у ланцужку",
|
||||||
"status.replied_to": "Адказаў {name}",
|
"status.replied_to": "Адказаў(-ла) {name}",
|
||||||
"status.reply": "Адказаць",
|
"status.reply": "Адказаць",
|
||||||
"status.replyAll": "Адказаць у ланцугу",
|
"status.replyAll": "Адказаць у ланцугу",
|
||||||
"status.report": "Паскардзіцца на @{name}",
|
"status.report": "Паскардзіцца на @{name}",
|
||||||
@ -988,7 +995,7 @@
|
|||||||
"visibility_modal.button_title": "Вызначыць бачнасць",
|
"visibility_modal.button_title": "Вызначыць бачнасць",
|
||||||
"visibility_modal.header": "Бачнасць і ўзаемадзеянне",
|
"visibility_modal.header": "Бачнасць і ўзаемадзеянне",
|
||||||
"visibility_modal.helper.direct_quoting": "Прыватныя згадванні, створаныя на Mastodon, нельга цытаваць іншым людзям.",
|
"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.privacy_private_self_quote": "Самацытаванне прыватных допісаў не можа быць публічным.",
|
||||||
"visibility_modal.helper.private_quoting": "Допісы для падпісчыкаў, створаныя на Mastodon, нельга цытаваць іншым людзям.",
|
"visibility_modal.helper.private_quoting": "Допісы для падпісчыкаў, створаныя на Mastodon, нельга цытаваць іншым людзям.",
|
||||||
"visibility_modal.helper.unlisted_quoting": "Калі людзі працытуюць Вас, іх допіс таксама будзе схаваны ад стужкі трэндаў.",
|
"visibility_modal.helper.unlisted_quoting": "Калі людзі працытуюць Вас, іх допіс таксама будзе схаваны ад стужкі трэндаў.",
|
||||||
|
|||||||
@ -729,7 +729,6 @@
|
|||||||
"privacy.public.long": "Всеки във и извън Mastodon",
|
"privacy.public.long": "Всеки във и извън Mastodon",
|
||||||
"privacy.public.short": "Публично",
|
"privacy.public.short": "Публично",
|
||||||
"privacy.unlisted.additional": "Това действие е точно като публичното, с изключение на това, че публикацията няма да се появява в каналите на живо, хаштаговете, разглеждането или търсенето в Mastodon, дори ако сте избрали да се публично видими на ниво акаунт.",
|
"privacy.unlisted.additional": "Това действие е точно като публичното, с изключение на това, че публикацията няма да се появява в каналите на живо, хаштаговете, разглеждането или търсенето в Mastodon, дори ако сте избрали да се публично видими на ниво акаунт.",
|
||||||
"privacy.unlisted.long": "По-малко алгоритмични фанфари",
|
|
||||||
"privacy.unlisted.short": "Тиха публика",
|
"privacy.unlisted.short": "Тиха публика",
|
||||||
"privacy_policy.last_updated": "Последно осъвременяване на {date}",
|
"privacy_policy.last_updated": "Последно осъвременяване на {date}",
|
||||||
"privacy_policy.title": "Политика за поверителност",
|
"privacy_policy.title": "Политика за поверителност",
|
||||||
@ -877,7 +876,6 @@
|
|||||||
"status.quote_post_author": "Цитирах публикация от @{name}",
|
"status.quote_post_author": "Цитирах публикация от @{name}",
|
||||||
"status.read_more": "Още за четене",
|
"status.read_more": "Още за четене",
|
||||||
"status.reblog": "Подсилване",
|
"status.reblog": "Подсилване",
|
||||||
"status.reblog_private": "Подсилване с оригиналната видимост",
|
|
||||||
"status.reblogged_by": "{name} подсили",
|
"status.reblogged_by": "{name} подсили",
|
||||||
"status.reblogs": "{count, plural, one {подсилване} other {подсилвания}}",
|
"status.reblogs": "{count, plural, one {подсилване} other {подсилвания}}",
|
||||||
"status.reblogs.empty": "Още никого не е подсилвал публикацията. Подсилващият ще се покаже тук.",
|
"status.reblogs.empty": "Още никого не е подсилвал публикацията. Подсилващият ще се покаже тук.",
|
||||||
@ -952,7 +950,6 @@
|
|||||||
"video.volume_up": "Увеличаване на звука",
|
"video.volume_up": "Увеличаване на звука",
|
||||||
"visibility_modal.button_title": "Задаване на видимост",
|
"visibility_modal.button_title": "Задаване на видимост",
|
||||||
"visibility_modal.header": "Видимост и взаимодействие",
|
"visibility_modal.header": "Видимост и взаимодействие",
|
||||||
"visibility_modal.helper.privacy_editing": "Публикуваните публикации не може да променят видимостта си.",
|
|
||||||
"visibility_modal.quote_followers": "Само последователи",
|
"visibility_modal.quote_followers": "Само последователи",
|
||||||
"visibility_modal.quote_public": "Някой"
|
"visibility_modal.quote_public": "Някой"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -377,7 +377,6 @@
|
|||||||
"status.pin": "নিজের পাতায় এটা পিন করতে",
|
"status.pin": "নিজের পাতায় এটা পিন করতে",
|
||||||
"status.read_more": "আরো পড়ুন",
|
"status.read_more": "আরো পড়ুন",
|
||||||
"status.reblog": "সমর্থন দিতে",
|
"status.reblog": "সমর্থন দিতে",
|
||||||
"status.reblog_private": "আপনার অনুসরণকারীদের কাছে এটার সমর্থন দেখাতে",
|
|
||||||
"status.reblogged_by": "{name} সমর্থন দিয়েছে",
|
"status.reblogged_by": "{name} সমর্থন দিয়েছে",
|
||||||
"status.reblogs.empty": "এখনো কেও এটাতে সমর্থন দেয়নি। যখন কেও দেয়, সেটা তখন এখানে দেখা যাবে।",
|
"status.reblogs.empty": "এখনো কেও এটাতে সমর্থন দেয়নি। যখন কেও দেয়, সেটা তখন এখানে দেখা যাবে।",
|
||||||
"status.redraft": "মুছে আবার নতুন করে লিখতে",
|
"status.redraft": "মুছে আবার নতুন করে লিখতে",
|
||||||
|
|||||||
@ -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.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.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.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.domain_blocks.suspended.title": "Astalet",
|
||||||
"about.language_label": "Yezh",
|
"about.language_label": "Yezh",
|
||||||
"about.not_available": "An titour-mañ ne c'heller ket gwelet war ar servijer-mañ.",
|
"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.powered_by": "Rouedad sokial digreizenned kaset gant {mastodon}",
|
||||||
"about.rules": "Reolennoù ar servijer",
|
"about.rules": "Reolennoù ar servijer",
|
||||||
|
"account.account_note_header": "Notenn bersonel",
|
||||||
"account.add_or_remove_from_list": "Ouzhpenn pe dilemel eus al listennadoù",
|
"account.add_or_remove_from_list": "Ouzhpenn pe dilemel eus al listennadoù",
|
||||||
"account.badges.bot": "Robot",
|
"account.badges.bot": "Robot",
|
||||||
"account.badges.group": "Strollad",
|
"account.badges.group": "Strollad",
|
||||||
@ -20,17 +21,22 @@
|
|||||||
"account.block_domain": "Stankañ an domani {domain}",
|
"account.block_domain": "Stankañ an domani {domain}",
|
||||||
"account.block_short": "Stankañ",
|
"account.block_short": "Stankañ",
|
||||||
"account.blocked": "Stanket",
|
"account.blocked": "Stanket",
|
||||||
|
"account.blocking": "Stanket",
|
||||||
"account.cancel_follow_request": "Nullañ ar reked heuliañ",
|
"account.cancel_follow_request": "Nullañ ar reked heuliañ",
|
||||||
"account.copy": "Eilañ al liamm war-zu ho profil",
|
"account.copy": "Eilañ al liamm war-zu ho profil",
|
||||||
"account.direct": "Menegiñ @{name} ent-prevez",
|
"account.direct": "Menegiñ @{name} ent-prevez",
|
||||||
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
|
"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.edit_profile": "Kemmañ ar profil",
|
||||||
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
||||||
"account.endorse": "Lakaat war-wel war ar profil",
|
"account.endorse": "Lakaat en a-raok war ar profil",
|
||||||
"account.familiar_followers_one": "Heuilhet gant {name1}",
|
"account.familiar_followers_one": "Heuliet gant {name1}",
|
||||||
"account.familiar_followers_two": "Heuilhet gant {name1} ha {name2}",
|
"account.familiar_followers_two": "Heuliet gant {name1} ha {name2}",
|
||||||
"account.featured_tags.last_status_at": "Toud diwezhañ : {date}",
|
"account.featured": "En a-raok",
|
||||||
"account.featured_tags.last_status_never": "Embannadur ebet",
|
"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": "Heuliañ",
|
||||||
"account.follow_back": "Heuliañ d'ho tro",
|
"account.follow_back": "Heuliañ d'ho tro",
|
||||||
"account.followers": "Tud koumanantet",
|
"account.followers": "Tud koumanantet",
|
||||||
@ -39,6 +45,7 @@
|
|||||||
"account.followers_you_know_counter": "{counter} a anavezit",
|
"account.followers_you_know_counter": "{counter} a anavezit",
|
||||||
"account.following": "Koumanantoù",
|
"account.following": "Koumanantoù",
|
||||||
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
|
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
|
||||||
|
"account.follows_you": "Ho heuilh",
|
||||||
"account.go_to_profile": "Gwelet ar profil",
|
"account.go_to_profile": "Gwelet ar profil",
|
||||||
"account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
|
"account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
|
||||||
"account.in_memoriam": "E koun.",
|
"account.in_memoriam": "E koun.",
|
||||||
@ -53,20 +60,25 @@
|
|||||||
"account.mute_notifications_short": "Kuzhat ar c'hemennoù",
|
"account.mute_notifications_short": "Kuzhat ar c'hemennoù",
|
||||||
"account.mute_short": "Kuzhat",
|
"account.mute_short": "Kuzhat",
|
||||||
"account.muted": "Kuzhet",
|
"account.muted": "Kuzhet",
|
||||||
|
"account.muting": "O kuzhat",
|
||||||
|
"account.mutual": "Heuliañ a rit an eil egile",
|
||||||
"account.no_bio": "Deskrivadur ebet da gaout.",
|
"account.no_bio": "Deskrivadur ebet da gaout.",
|
||||||
"account.open_original_page": "Digeriñ ar bajenn orin",
|
"account.open_original_page": "Digeriñ ar bajenn orin",
|
||||||
"account.posts": "Embannadurioù",
|
"account.posts": "Embannadurioù",
|
||||||
"account.posts_with_replies": "Embannadurioù ha respontoù",
|
"account.posts_with_replies": "Embannadurioù ha respontoù",
|
||||||
|
"account.remove_from_followers": "Dilemel {name} eus an heulierien·ezed",
|
||||||
"account.report": "Disklêriañ @{name}",
|
"account.report": "Disklêriañ @{name}",
|
||||||
"account.requested": "O c'hortoz an asant. Klikit evit nullañ ar goulenn heuliañ",
|
"account.requested": "O c'hortoz an asant. Klikit evit nullañ ar goulenn heuliañ",
|
||||||
"account.requested_follow": "Gant {name} eo bet goulennet ho 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.share": "Skignañ profil @{name}",
|
||||||
"account.show_reblogs": "Diskouez skignadennoù @{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.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": "Distankañ @{name}",
|
||||||
"account.unblock_domain": "Diverzañ an domani {domain}",
|
"account.unblock_domain": "Distankañ an domani {domain}",
|
||||||
|
"account.unblock_domain_short": "Distankañ",
|
||||||
"account.unblock_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.unfollow": "Diheuliañ",
|
||||||
"account.unmute": "Diguzhat @{name}",
|
"account.unmute": "Diguzhat @{name}",
|
||||||
"account.unmute_notifications_short": "Diguzhat ar c'hemennoù",
|
"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.monthly_retention": "Feur azdalc'h an implijerien·ezed dre viz goude bezañ lakaet o anv",
|
||||||
"admin.dashboard.retention.average": "Keidenn",
|
"admin.dashboard.retention.average": "Keidenn",
|
||||||
"admin.dashboard.retention.cohort": "Miz an enrolladur",
|
"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_accounts": "Profiloù kontoù a vefe dilamet",
|
||||||
"admin.impact_report.instance_followers": "Heulierien a gollfe hon implijerien",
|
"admin.impact_report.instance_followers": "Heulierien a gollfe hon implijerien·ezed",
|
||||||
"admin.impact_report.instance_follows": "Heulierien a gollfe o implijerien",
|
"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.message": "Klaskit en-dro a-benn {retry_time, time, medium}.",
|
||||||
"alert.rate_limited.title": "Feur bevennet",
|
"alert.rate_limited.title": "Feur bevennet",
|
||||||
"alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
|
"alert.unexpected.message": "Ur fazi dic'hortozet zo degouezhet.",
|
||||||
"alert.unexpected.title": "Hopala !",
|
"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.cancel": "Nullañ",
|
||||||
"alt_text_modal.change_thumbnail": "Kemmañ ar velvenn",
|
"alt_text_modal.change_thumbnail": "Kemmañ ar velvenn",
|
||||||
"alt_text_modal.done": "Graet",
|
"alt_text_modal.done": "Graet",
|
||||||
"announcement.announcement": "Kemennad",
|
"announcement.announcement": "Kemennad",
|
||||||
"annual_report.summary.followers.followers": "heulier",
|
"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.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.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)",
|
"attachments_list.unprocessed": "(ket meret)",
|
||||||
"audio.hide": "Kuzhat ar c'hleved",
|
"audio.hide": "Kuzhat ar c'hleved",
|
||||||
"block_modal.show_less": "Diskouez nebeutoc'h",
|
"block_modal.show_less": "Diskouez nebeutoc'h",
|
||||||
"block_modal.show_more": "Diskouez muioc'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.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.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.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 !",
|
"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.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",
|
"closed_registrations_modal.title": "Enskrivadurioù war Mastodon",
|
||||||
"column.about": "Diwar-benn",
|
"column.about": "Diwar-benn",
|
||||||
"column.blocks": "Implijer·ezed·ien berzet",
|
"column.blocks": "Implijerien·ezed stanket",
|
||||||
"column.bookmarks": "Sinedoù",
|
"column.bookmarks": "Sinedoù",
|
||||||
"column.community": "Red-amzer lec'hel",
|
"column.community": "Red-amzer lec'hel",
|
||||||
"column.create_list": "Krouiñ ul listenn",
|
"column.create_list": "Krouiñ ul listenn",
|
||||||
"column.direct": "Menegoù prevez",
|
"column.direct": "Menegoù prevez",
|
||||||
"column.directory": "Mont a-dreuz ar profiloù",
|
"column.directory": "Mont a-dreuz ar profiloù",
|
||||||
"column.domain_blocks": "Domani berzet",
|
"column.domain_blocks": "Domani stanket",
|
||||||
"column.edit_list": "Kemmañ al listenn",
|
"column.edit_list": "Kemmañ al listenn",
|
||||||
"column.favourites": "Muiañ-karet",
|
"column.favourites": "Muiañ-karet",
|
||||||
"column.firehose": "Redoù war-eeun",
|
"column.firehose": "Redoù war-eeun",
|
||||||
"column.follow_requests": "Rekedoù heuliañ",
|
"column.follow_requests": "Rekedoù heuliañ",
|
||||||
"column.home": "Degemer",
|
"column.home": "Degemer",
|
||||||
"column.lists": "Listennoù",
|
"column.lists": "Listennoù",
|
||||||
"column.mutes": "Implijer·ion·ezed kuzhet",
|
"column.mutes": "Implijerien·ezed kuzhet",
|
||||||
"column.notifications": "Kemennoù",
|
"column.notifications": "Kemennoù",
|
||||||
"column.pins": "Embannadurioù spilhennet",
|
"column.pins": "Embannadurioù spilhennet",
|
||||||
"column.public": "Red-amzer kevredet",
|
"column.public": "Red-amzer kevredet",
|
||||||
@ -148,9 +172,9 @@
|
|||||||
"compose.published.open": "Digeriñ",
|
"compose.published.open": "Digeriñ",
|
||||||
"compose.saved.body": "Enrollet.",
|
"compose.saved.body": "Enrollet.",
|
||||||
"compose_form.direct_message_warning_learn_more": "Gouzout hiroc'h",
|
"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.encryption_warning": "Embannadurioù war Mastodon na vezont ket sifret a-benn-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.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 ho toudoù prevez.",
|
"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.lock_disclaimer.lock": "prennet",
|
||||||
"compose_form.placeholder": "Petra emaoc'h o soñjal e-barzh ?",
|
"compose_form.placeholder": "Petra emaoc'h o soñjal e-barzh ?",
|
||||||
"compose_form.poll.duration": "Pad ar sontadeg",
|
"compose_form.poll.duration": "Pad ar sontadeg",
|
||||||
@ -167,23 +191,41 @@
|
|||||||
"confirmation_modal.cancel": "Nullañ",
|
"confirmation_modal.cancel": "Nullañ",
|
||||||
"confirmations.block.confirm": "Stankañ",
|
"confirmations.block.confirm": "Stankañ",
|
||||||
"confirmations.delete.confirm": "Dilemel",
|
"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.confirm": "Dilemel",
|
||||||
"confirmations.delete_list.message": "Ha sur eo hoc'h eus c'hoant da zilemel ar roll-mañ da vat ?",
|
"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.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.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.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.follow_to_list.title": "Heuliañ an implijer·ez?",
|
||||||
"confirmations.logout.confirm": "Digevreañ",
|
"confirmations.logout.confirm": "Digevreañ",
|
||||||
"confirmations.logout.message": "Ha sur oc'h e fell deoc'h 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.mute.confirm": "Kuzhat",
|
||||||
"confirmations.redraft.confirm": "Diverkañ ha skrivañ en-dro",
|
"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.confirm": "Diheuliañ",
|
||||||
"confirmations.unfollow.message": "Ha sur oc'h e fell deoc'h paouez da heuliañ {name} ?",
|
"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",
|
"content_warning.show_more": "Diskouez muioc'h",
|
||||||
"conversation.delete": "Dilemel ar gaozeadenn",
|
"conversation.delete": "Dilemel ar gaozeadenn",
|
||||||
"conversation.mark_as_read": "Merkañ evel lennet",
|
"conversation.mark_as_read": "Merkañ evel lennet",
|
||||||
"conversation.open": "Gwelout ar gaozeadenn",
|
"conversation.open": "Gwelet ar gaozeadenn",
|
||||||
"conversation.with": "Gant {names}",
|
"conversation.with": "Gant {names}",
|
||||||
"copy_icon_button.copied": "Eilet er golver",
|
"copy_icon_button.copied": "Eilet er golver",
|
||||||
"copypaste.copied": "Eilet",
|
"copypaste.copied": "Eilet",
|
||||||
@ -194,11 +236,17 @@
|
|||||||
"directory.recently_active": "Oberiant nevez zo",
|
"directory.recently_active": "Oberiant nevez zo",
|
||||||
"disabled_account_banner.account_settings": "Arventennoù ar gont",
|
"disabled_account_banner.account_settings": "Arventennoù ar gont",
|
||||||
"disabled_account_banner.text": "Ho kont {disabledAccount} zo divev evit bremañ.",
|
"disabled_account_banner.text": "Ho kont {disabledAccount} zo divev evit bremañ.",
|
||||||
"dismissable_banner.community_timeline": "Setu toudoù foran nevesañ an dud a zo herberc’hiet o c'hontoù gant {domain}.",
|
"dismissable_banner.community_timeline": "Setu embannadurioù foran nevesañ an dud a zo herberc’hiet o c’hontoù gant {domain}.",
|
||||||
"dismissable_banner.dismiss": "Diverkañ",
|
"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.server": "Dafariad",
|
||||||
"domain_pill.username": "Anv-implijer",
|
"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 lec’hienn en ur eilañ ar c’hod amañ-dindan.",
|
||||||
"embed.preview": "Setu penaos e teuio war wel :",
|
"embed.preview": "Setu penaos e teuio war wel :",
|
||||||
"emoji_button.activity": "Obererezh",
|
"emoji_button.activity": "Obererezh",
|
||||||
"emoji_button.clear": "Diverkañ",
|
"emoji_button.clear": "Diverkañ",
|
||||||
@ -215,22 +263,22 @@
|
|||||||
"emoji_button.search_results": "Disoc'hoù an enklask",
|
"emoji_button.search_results": "Disoc'hoù an enklask",
|
||||||
"emoji_button.symbols": "Arouezioù",
|
"emoji_button.symbols": "Arouezioù",
|
||||||
"emoji_button.travel": "Beajiñ & Lec'hioù",
|
"emoji_button.travel": "Beajiñ & Lec'hioù",
|
||||||
"empty_column.account_suspended": "Kont ehanet",
|
"empty_column.account_suspended": "Kont astalet",
|
||||||
"empty_column.account_timeline": "Toud ebet amañ !",
|
"empty_column.account_timeline": "Embannadur ebet amañ!",
|
||||||
"empty_column.account_unavailable": "Profil dihegerz",
|
"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.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.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.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.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.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 en hashtag-mañ c'hoazh.",
|
"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.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.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 ebet c'hoazh.",
|
"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.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": "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.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.",
|
"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.title": "Diouzh ar c'hiz",
|
||||||
"explore.trending_links": "Keleier",
|
"explore.trending_links": "Keleier",
|
||||||
"explore.trending_statuses": "Embannadurioù",
|
"explore.trending_statuses": "Embannadurioù",
|
||||||
"explore.trending_tags": "Hashtagoù",
|
"explore.trending_tags": "Gerioù-klik",
|
||||||
"featured_carousel.next": "War-raok",
|
"featured_carousel.next": "War-raok",
|
||||||
"featured_carousel.post": "Embannadenn",
|
"featured_carousel.post": "Embannadur",
|
||||||
"featured_carousel.previous": "War-gil",
|
"featured_carousel.previous": "War-gil",
|
||||||
"featured_carousel.slide": "{index} diwar {total}",
|
"featured_carousel.slide": "{index} diwar {total}",
|
||||||
"filter_modal.added.context_mismatch_title": "Kenarroud digenglotus !",
|
"filter_modal.added.context_mismatch_title": "Kenarroud digenglotus !",
|
||||||
"filter_modal.added.expired_title": "Sil deuet d'e dermen !",
|
"filter_modal.added.expired_title": "Sil deuet d'e dermen !",
|
||||||
"filter_modal.added.review_and_configure_title": "Arventennoù ar sil",
|
"filter_modal.added.review_and_configure_title": "Arventennoù ar sil",
|
||||||
"filter_modal.added.settings_link": "pajenn an arventennoù",
|
"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.added.title": "Sil ouzhpennet !",
|
||||||
"filter_modal.select_filter.context_mismatch": "na glot ket gant ar c'henarroud-mañ",
|
"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.expired": "zo deuet d'e dermen",
|
||||||
"filter_modal.select_filter.prompt_new": "Rummad nevez : {name}",
|
"filter_modal.select_filter.prompt_new": "Rummad nevez : {name}",
|
||||||
"filter_modal.select_filter.search": "Klask pe krouiñ",
|
"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.subtitle": "Implijout ur rummad a zo anezhañ pe krouiñ unan nevez",
|
||||||
"filter_modal.select_filter.title": "Silañ an toud-mañ",
|
"filter_modal.select_filter.title": "Silañ an embannadur-mañ",
|
||||||
"filter_modal.title.status": "Silañ un toud",
|
"filter_modal.title.status": "Silañ un embannadur",
|
||||||
"filter_warning.matches_filter": "A glot gant ar sil “<span>{title}</span>”",
|
"filter_warning.matches_filter": "A glot gant ar sil “<span>{title}</span>”",
|
||||||
"firehose.all": "Pep tra",
|
"firehose.all": "Pep tra",
|
||||||
"firehose.local": "Ar servijer-mañ",
|
"firehose.local": "Ar servijer-mañ",
|
||||||
@ -266,10 +314,12 @@
|
|||||||
"follow_request.authorize": "Aotren",
|
"follow_request.authorize": "Aotren",
|
||||||
"follow_request.reject": "Nac'hañ",
|
"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_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.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.popular_suggestion_longer": "Diouzh ar c'hiz war {domain}",
|
||||||
"follow_suggestions.view_all": "Gwelet pep tra",
|
"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.about": "Diwar-benn",
|
||||||
"footer.directory": "Kavlec'h ar profiloù",
|
"footer.directory": "Kavlec'h ar profiloù",
|
||||||
"footer.get_app": "Pellgargañ an arload",
|
"footer.get_app": "Pellgargañ an arload",
|
||||||
@ -281,20 +331,26 @@
|
|||||||
"generic.saved": "Enrollet",
|
"generic.saved": "Enrollet",
|
||||||
"getting_started.heading": "Loc'hañ",
|
"getting_started.heading": "Loc'hañ",
|
||||||
"hashtag.admin_moderation": "Digeriñ an etrefas evezhiañ evit #{name}",
|
"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.all": "ha(g) {additional}",
|
||||||
"hashtag.column_header.tag_mode.any": "pe {additional}",
|
"hashtag.column_header.tag_mode.any": "pe {additional}",
|
||||||
"hashtag.column_header.tag_mode.none": "hep {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.no_options_message": "N'eus bet kavet ali ebet",
|
||||||
"hashtag.column_settings.select.placeholder": "Ouzhpennañ hashtagoù…",
|
"hashtag.column_settings.select.placeholder": "Ouzhpennañ gerioù-klik…",
|
||||||
"hashtag.column_settings.tag_mode.all": "An holl anezho",
|
"hashtag.column_settings.tag_mode.all": "An holl anezho",
|
||||||
"hashtag.column_settings.tag_mode.any": "Unan e mesk anezho",
|
"hashtag.column_settings.tag_mode.any": "Unan e mesk anezho",
|
||||||
"hashtag.column_settings.tag_mode.none": "Hini ebet 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.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": "{count, plural, one {{counter} embannadur} other {{counter} embannadur}}",
|
||||||
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} embannadur} other {{counter} embannadur}} hiziv",
|
"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.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}}",
|
"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_quotes": "Diskouez an arroudennoù",
|
||||||
"home.column_settings.show_reblogs": "Diskouez ar skignadennoù",
|
"home.column_settings.show_reblogs": "Diskouez ar skignadennoù",
|
||||||
"home.column_settings.show_replies": "Diskouez ar respontoù",
|
"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.body": "Hizivait ho servijer Mastodon kerkent ha ma c'hallit mar plij!",
|
||||||
"home.pending_critical_update.link": "Gwelet an hizivadennoù",
|
"home.pending_critical_update.link": "Gwelet an hizivadennoù",
|
||||||
"home.show_announcements": "Diskouez ar c'hemennoù",
|
"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_another_server": "War ur servijer all",
|
||||||
"interaction_modal.on_this_server": "War ar servijer-mañ",
|
"interaction_modal.on_this_server": "War ar servijer-mañ",
|
||||||
"interaction_modal.title.favourite": "Ouzhpennañ embannadur {name} d'ar re vuiañ-karet",
|
"interaction_modal.title.favourite": "Ouzhpennañ embannadur {name} d'ar re vuiañ-karet",
|
||||||
"interaction_modal.title.follow": "Heuliañ {name}",
|
"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.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.days": "{number, plural, one {# devezh} other{# a zevezhioù}}",
|
||||||
"intervals.full.hours": "{number, plural, one {# eurvezh} other{# eurvezh}}",
|
"intervals.full.hours": "{number, plural, one {# eurvezh} other{# eurvezh}}",
|
||||||
"intervals.full.minutes": "{number, plural, one {# munut} other{# a vunutoù}}",
|
"intervals.full.minutes": "{number, plural, one {# munut} other{# a vunutoù}}",
|
||||||
"keyboard_shortcuts.back": "Distreiñ",
|
"keyboard_shortcuts.back": "Distreiñ",
|
||||||
"keyboard_shortcuts.blocked": "Digeriñ roll an implijer.ezed.rien stanket",
|
"keyboard_shortcuts.blocked": "Digeriñ roll an implijerien·ezed stanket",
|
||||||
"keyboard_shortcuts.boost": "Skignañ an toud",
|
"keyboard_shortcuts.boost": "Skignañ an embannadur",
|
||||||
"keyboard_shortcuts.column": "Fokus ar bann",
|
"keyboard_shortcuts.column": "Fokus ar bann",
|
||||||
"keyboard_shortcuts.compose": "Fokus an takad testenn",
|
"keyboard_shortcuts.compose": "Fokus an takad testenn",
|
||||||
"keyboard_shortcuts.description": "Deskrivadur",
|
"keyboard_shortcuts.description": "Deskrivadur",
|
||||||
"keyboard_shortcuts.direct": "to open direct messages column",
|
"keyboard_shortcuts.direct": "to open direct messages column",
|
||||||
"keyboard_shortcuts.down": "Diskennañ er roll",
|
"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.favourite": "Ouzhpennañ an embannadur d'ar re vuiañ-karet",
|
||||||
"keyboard_shortcuts.favourites": "Digeriñ roll an embannadurioù muiañ-karet",
|
"keyboard_shortcuts.favourites": "Digeriñ roll an embannadurioù muiañ-karet",
|
||||||
"keyboard_shortcuts.federated": "Digeriñ ar red-amzer kevredet",
|
"keyboard_shortcuts.federated": "Digeriñ ar red-amzer kevredet",
|
||||||
@ -329,74 +388,100 @@
|
|||||||
"keyboard_shortcuts.legend": "Skrammañ ar skrid-se",
|
"keyboard_shortcuts.legend": "Skrammañ ar skrid-se",
|
||||||
"keyboard_shortcuts.local": "Digeriñ red-amzer lec'hel",
|
"keyboard_shortcuts.local": "Digeriñ red-amzer lec'hel",
|
||||||
"keyboard_shortcuts.mention": "Menegiñ an aozer.ez",
|
"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.my_profile": "Digeriñ ho profil",
|
||||||
"keyboard_shortcuts.notifications": "Digeriñ bann ar c'hemennoù",
|
"keyboard_shortcuts.notifications": "Digeriñ bann ar c'hemennoù",
|
||||||
"keyboard_shortcuts.open_media": "Digeriñ ar media",
|
"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.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.requests": "Digeriñ roll goulennoù heuliañ",
|
||||||
"keyboard_shortcuts.search": "Fokus barenn klask",
|
"keyboard_shortcuts.search": "Fokus barenn klask",
|
||||||
"keyboard_shortcuts.spoilers": "da guzhat/ziguzhat tachenn CW",
|
"keyboard_shortcuts.spoilers": "da guzhat/ziguzhat tachenn CW",
|
||||||
"keyboard_shortcuts.start": "Digeriñ bann \"Kregiñ\"",
|
"keyboard_shortcuts.start": "Digeriñ bann \"Kregiñ\"",
|
||||||
"keyboard_shortcuts.toggle_hidden": "da guzhat/ziguzhat an desten a-dreñv CW",
|
"keyboard_shortcuts.toggle_hidden": "da guzhat/ziguzhat an desten a-dreñv CW",
|
||||||
"keyboard_shortcuts.toggle_sensitivity": "da guzhat/ziguzhat ur media",
|
"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.unfocus": "Difokus an dachenn testenn/klask",
|
||||||
"keyboard_shortcuts.up": "Pignat er roll",
|
"keyboard_shortcuts.up": "Pignat er roll",
|
||||||
|
"learn_more_link.got_it": "Mat eo",
|
||||||
"lightbox.close": "Serriñ",
|
"lightbox.close": "Serriñ",
|
||||||
"lightbox.next": "Da-heul",
|
"lightbox.next": "Da-heul",
|
||||||
"lightbox.previous": "A-raok",
|
"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}.",
|
"limited_account_hint.title": "Kuzhet eo bet ar profil-mañ gant an evezhierien eus {domain}.",
|
||||||
"link_preview.author": "Gant {name}",
|
"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_member": "Ouzhpennañ",
|
||||||
"lists.add_to_list": "Ouzhpennañ d'al listenn",
|
"lists.add_to_list": "Ouzhpennañ d'al listenn",
|
||||||
"lists.create": "Krouiñ",
|
"lists.create": "Krouiñ",
|
||||||
|
"lists.create_a_list_to_organize": "Krouit ul listenn evit renkañ ho red degemer",
|
||||||
"lists.create_list": "Krouiñ ul listenn",
|
"lists.create_list": "Krouiñ ul listenn",
|
||||||
"lists.delete": "Dilemel al listenn",
|
"lists.delete": "Dilemel al listenn",
|
||||||
"lists.done": "Graet",
|
"lists.done": "Graet",
|
||||||
"lists.edit": "Kemmañ al listenn",
|
"lists.edit": "Kemmañ al listenn",
|
||||||
"lists.list_name": "Anv 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.list": "Izili ar roll",
|
||||||
"lists.replies_policy.none": "Den ebet",
|
"lists.replies_policy.none": "Den ebet",
|
||||||
|
"lists.save": "Enrollañ",
|
||||||
|
"lists.search": "Klask",
|
||||||
"load_pending": "{count, plural, one {# dra nevez} other {# dra nevez}}",
|
"load_pending": "{count, plural, one {# dra nevez} other {# dra nevez}}",
|
||||||
"loading_indicator.label": "O kargañ…",
|
"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.about": "Diwar-benn",
|
||||||
"navigation_bar.account_settings": "Ger-tremen ha surentez",
|
"navigation_bar.account_settings": "Ger-tremen ha surentez",
|
||||||
|
"navigation_bar.administration": "Merañ",
|
||||||
"navigation_bar.automated_deletion": "Dilemel an embannadenn ent-emgefreek",
|
"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.bookmarks": "Sinedoù",
|
||||||
"navigation_bar.direct": "Menegoù prevez",
|
"navigation_bar.direct": "Menegoù prevez",
|
||||||
"navigation_bar.domain_blocks": "Domanioù kuzhet",
|
"navigation_bar.domain_blocks": "Domanioù stanket",
|
||||||
"navigation_bar.favourites": "Muiañ-karet",
|
"navigation_bar.favourites": "Muiañ-karet",
|
||||||
"navigation_bar.filters": "Gerioù kuzhet",
|
"navigation_bar.filters": "Gerioù kuzhet",
|
||||||
"navigation_bar.follow_requests": "Pedadoù heuliañ",
|
"navigation_bar.follow_requests": "Rekedoù heuliañ",
|
||||||
"navigation_bar.followed_tags": "Hashtagoù o heuliañ",
|
"navigation_bar.followed_tags": "Gerioù-klik o heuliañ",
|
||||||
"navigation_bar.follows_and_followers": "Heuliadennoù ha heulier·ezed·ien",
|
"navigation_bar.follows_and_followers": "Heuliadennoù ha heulier·ezed·ien",
|
||||||
"navigation_bar.import_export": "Enporzhiañ hag ezporzhiañ",
|
"navigation_bar.import_export": "Enporzhiañ hag ezporzhiañ",
|
||||||
"navigation_bar.lists": "Listennoù",
|
"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.more": "Muioc'h",
|
||||||
"navigation_bar.mutes": "Implijer·ion·ezed kuzhet",
|
"navigation_bar.mutes": "Implijerien·ezed kuzhet",
|
||||||
"navigation_bar.preferences": "Gwellvezioù",
|
"navigation_bar.preferences": "Gwellvezioù",
|
||||||
"navigation_bar.search": "Klask",
|
"navigation_bar.search": "Klask",
|
||||||
"navigation_bar.search_trends": "Klask / Diouzh ar c'hiz",
|
"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.",
|
"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.report": "Disklêriet eo bet {target} gant {name}",
|
||||||
"notification.admin.sign_up": "{name} en·he deus lakaet e·hec'h anv",
|
"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": "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.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.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.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.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.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.relationships_severance_event.learn_more": "Gouzout hiroc'h",
|
||||||
"notification.status": "Emañ {name} o paouez toudañ",
|
"notification.status": "Emañ {name} o paouez embann",
|
||||||
"notification.update": "Gant {name} ez eus bet kemmet un toud",
|
"notification.update": "Gant {name} ez eus bet kemmet un embannadur",
|
||||||
"notification_requests.accept": "Asantiñ",
|
"notification_requests.accept": "Asantiñ",
|
||||||
"notification_requests.dismiss": "Diverkañ",
|
"notification_requests.dismiss": "Diverkañ",
|
||||||
"notification_requests.edit_selection": "Kemmañ",
|
"notification_requests.edit_selection": "Kemmañ",
|
||||||
@ -408,15 +493,16 @@
|
|||||||
"notifications.column_settings.alert": "Kemennoù war ar burev",
|
"notifications.column_settings.alert": "Kemennoù war ar burev",
|
||||||
"notifications.column_settings.favourite": "Muiañ-karet:",
|
"notifications.column_settings.favourite": "Muiañ-karet:",
|
||||||
"notifications.column_settings.follow": "Heulierien nevez:",
|
"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.group": "Strollañ",
|
||||||
"notifications.column_settings.mention": "Menegoù:",
|
"notifications.column_settings.mention": "Menegoù:",
|
||||||
"notifications.column_settings.poll": "Disoc'hoù ar sontadeg:",
|
"notifications.column_settings.poll": "Disoc'hoù ar sontadeg:",
|
||||||
"notifications.column_settings.push": "Kemennoù push",
|
"notifications.column_settings.push": "Kemennoù push",
|
||||||
|
"notifications.column_settings.quote": "Menegoù:",
|
||||||
"notifications.column_settings.reblog": "Skignadennoù:",
|
"notifications.column_settings.reblog": "Skignadennoù:",
|
||||||
"notifications.column_settings.show": "Diskouez er bann",
|
"notifications.column_settings.show": "Diskouez er bann",
|
||||||
"notifications.column_settings.sound": "Seniñ",
|
"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.category": "Kemennoù anlennet",
|
||||||
"notifications.column_settings.unread_notifications.highlight": "Uslinennañ ar c'hemennoù anlennet",
|
"notifications.column_settings.unread_notifications.highlight": "Uslinennañ ar c'hemennoù anlennet",
|
||||||
"notifications.column_settings.update": "Kemmoù:",
|
"notifications.column_settings.update": "Kemmoù:",
|
||||||
@ -438,6 +524,8 @@
|
|||||||
"notifications.policy.drop": "Tremen e-bioù",
|
"notifications.policy.drop": "Tremen e-bioù",
|
||||||
"notifications.policy.filter": "Silañ",
|
"notifications.policy.filter": "Silañ",
|
||||||
"notifications.policy.filter_new_accounts_title": "Kontoù nevez",
|
"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.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.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",
|
"notifications_permission_banner.title": "Na vankit netra morse",
|
||||||
@ -447,8 +535,9 @@
|
|||||||
"onboarding.profile.display_name": "Anv diskouezet",
|
"onboarding.profile.display_name": "Anv diskouezet",
|
||||||
"onboarding.profile.display_name_hint": "Hoc'h anv klok pe hoc'h anv fentus…",
|
"onboarding.profile.display_name_hint": "Hoc'h anv klok pe hoc'h anv fentus…",
|
||||||
"onboarding.profile.note": "Berr-ha-berr",
|
"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 #gerioù-klik…",
|
||||||
"onboarding.profile.save_and_continue": "Enrollañ ha kenderc'hel",
|
"onboarding.profile.save_and_continue": "Enrollañ ha kenderc'hel",
|
||||||
|
"onboarding.profile.title": "Kefluniañ ar profil",
|
||||||
"onboarding.profile.upload_avatar": "Enporzhiañ ur skeudenn profil",
|
"onboarding.profile.upload_avatar": "Enporzhiañ ur skeudenn profil",
|
||||||
"password_confirmation.mismatching": "Disheñvel eo an daou c'her-termen-se",
|
"password_confirmation.mismatching": "Disheñvel eo an daou c'her-termen-se",
|
||||||
"picture_in_picture.restore": "Adlakaat",
|
"picture_in_picture.restore": "Adlakaat",
|
||||||
@ -463,12 +552,18 @@
|
|||||||
"poll_button.add_poll": "Ouzhpennañ ur sontadeg",
|
"poll_button.add_poll": "Ouzhpennañ ur sontadeg",
|
||||||
"poll_button.remove_poll": "Dilemel ar sontadeg",
|
"poll_button.remove_poll": "Dilemel ar sontadeg",
|
||||||
"privacy.change": "Cheñch prevezded an embannadur",
|
"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.private.short": "Heulierien",
|
||||||
"privacy.public.short": "Publik",
|
"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.last_updated": "Hizivadenn ziwezhañ {date}",
|
||||||
"privacy_policy.title": "Reolennoù Prevezded",
|
"privacy_policy.title": "Reolennoù Prevezded",
|
||||||
|
"quote_error.poll": "N'haller ket menegiñ sontadegoù.",
|
||||||
"recommended": "Erbedet",
|
"recommended": "Erbedet",
|
||||||
"refresh": "Freskaat",
|
"refresh": "Freskaat",
|
||||||
|
"regeneration_indicator.preparing_your_home_feed": "O prientiñ ho red degemer…",
|
||||||
"relative_time.days": "{number}d",
|
"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.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",
|
"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.minutes": "{number}m",
|
||||||
"relative_time.seconds": "{number}eil",
|
"relative_time.seconds": "{number}eil",
|
||||||
"relative_time.today": "hiziv",
|
"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.cancel": "Nullañ",
|
||||||
"reply_indicator.poll": "Sontadeg",
|
"reply_indicator.poll": "Sontadeg",
|
||||||
"report.block": "Stankañ",
|
"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.legal": "Lezennel",
|
||||||
"report.categories.other": "All",
|
"report.categories.other": "All",
|
||||||
"report.categories.spam": "Spam",
|
"report.categories.spam": "Spam",
|
||||||
@ -491,13 +588,13 @@
|
|||||||
"report.category.subtitle": "Choazit ar pezh a glot ar gwellañ",
|
"report.category.subtitle": "Choazit ar pezh a glot ar gwellañ",
|
||||||
"report.category.title": "Lârit deomp petra c'hoarvez gant {type}",
|
"report.category.title": "Lârit deomp petra c'hoarvez gant {type}",
|
||||||
"report.category.title_account": "profil",
|
"report.category.title_account": "profil",
|
||||||
"report.category.title_status": "an toud-mañ",
|
"report.category.title_status": "an embannadur-mañ",
|
||||||
"report.close": "Graet",
|
"report.close": "Graet",
|
||||||
"report.comment.title": "Ha traoù all a rankfemp gouzout ?",
|
"report.comment.title": "Ha traoù all a rankfemp gouzout ?",
|
||||||
"report.forward": "Treuzkas da: {target}",
|
"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.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": "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.next": "War-raok",
|
||||||
"report.placeholder": "Askelennoù ouzhpenn",
|
"report.placeholder": "Askelennoù ouzhpenn",
|
||||||
"report.reasons.dislike": "Ne blij ket din",
|
"report.reasons.dislike": "Ne blij ket din",
|
||||||
@ -512,7 +609,7 @@
|
|||||||
"report.rules.subtitle": "Diuzit an holl draoù a glot",
|
"report.rules.subtitle": "Diuzit an holl draoù a glot",
|
||||||
"report.rules.title": "Pesort reolennoù zo bet torret ?",
|
"report.rules.title": "Pesort reolennoù zo bet torret ?",
|
||||||
"report.statuses.subtitle": "Diuzit an holl draoù a glot",
|
"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.submit": "Kinnig",
|
||||||
"report.target": "O tisklêriañ {target}",
|
"report.target": "O tisklêriañ {target}",
|
||||||
"report.thanks.title": "Ne fell ket deoc'h gwelet an dra-se ?",
|
"report.thanks.title": "Ne fell ket deoc'h gwelet an dra-se ?",
|
||||||
@ -528,9 +625,9 @@
|
|||||||
"search.placeholder": "Klask",
|
"search.placeholder": "Klask",
|
||||||
"search.quick_action.account_search": "Profiloù a glot gant {x}",
|
"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_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.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.search_or_paste": "Klask pe pegañ un URL",
|
||||||
"search_popout.full_text_search_disabled_message": "N'eo ket da gaout war {domain}.",
|
"search_popout.full_text_search_disabled_message": "N'eo ket da gaout war {domain}.",
|
||||||
"search_popout.language_code": "Kod yezh ISO",
|
"search_popout.language_code": "Kod yezh ISO",
|
||||||
@ -541,7 +638,7 @@
|
|||||||
"search_popout.user": "implijer·ez",
|
"search_popout.user": "implijer·ez",
|
||||||
"search_results.accounts": "Profiloù",
|
"search_results.accounts": "Profiloù",
|
||||||
"search_results.all": "Pep tra",
|
"search_results.all": "Pep tra",
|
||||||
"search_results.hashtags": "Hashtagoù",
|
"search_results.hashtags": "Gerioù-klik",
|
||||||
"search_results.no_results": "Disoc'h ebet.",
|
"search_results.no_results": "Disoc'h ebet.",
|
||||||
"search_results.see_all": "Gwelet pep tra",
|
"search_results.see_all": "Gwelet pep tra",
|
||||||
"search_results.statuses": "Toudoù",
|
"search_results.statuses": "Toudoù",
|
||||||
@ -550,18 +647,21 @@
|
|||||||
"server_banner.server_stats": "Stadegoù ar servijer :",
|
"server_banner.server_stats": "Stadegoù ar servijer :",
|
||||||
"sign_in_banner.create_account": "Krouiñ ur gont",
|
"sign_in_banner.create_account": "Krouiñ ur gont",
|
||||||
"sign_in_banner.sign_in": "Kevreañ",
|
"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_account": "Digeriñ etrefas evezhiañ evit @{name}",
|
||||||
"status.admin_domain": "Digeriñ an etrefas evezhiañ evit {domain}",
|
"status.admin_domain": "Digeriñ an etrefas evezhiañ evit {domain}",
|
||||||
"status.admin_status": "Digeriñ an embannadenn e-barzh an etrefas evezhiañ",
|
"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.bookmark": "Ouzhpennañ d'ar sinedoù",
|
||||||
"status.cancel_reblog_private": "Nac'hañ ar skignadenn",
|
"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.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet",
|
||||||
"status.context.load_new_replies": "Respontoù nevez zo",
|
"status.context.load_new_replies": "Respontoù nevez zo",
|
||||||
"status.context.loading": "O kerc'hat muioc'h a respontoù",
|
"status.context.loading": "O kerc'hat muioc'h a respontoù",
|
||||||
"status.copy": "Eilañ liamm ar c'hannad",
|
"status.copy": "Eilañ liamm ar c'hannad",
|
||||||
"status.delete": "Dilemel",
|
"status.delete": "Dilemel",
|
||||||
|
"status.delete.success": "Embannadur dilamet",
|
||||||
"status.detailed_status": "Gwel kaozeadenn munudek",
|
"status.detailed_status": "Gwel kaozeadenn munudek",
|
||||||
"status.direct": "Menegiñ @{name} ent-prevez",
|
"status.direct": "Menegiñ @{name} ent-prevez",
|
||||||
"status.direct_indicator": "Meneg prevez",
|
"status.direct_indicator": "Meneg prevez",
|
||||||
@ -582,13 +682,19 @@
|
|||||||
"status.mute_conversation": "Kuzhat ar gaozeadenn",
|
"status.mute_conversation": "Kuzhat ar gaozeadenn",
|
||||||
"status.open": "Digeriñ ar c'hannad-mañ",
|
"status.open": "Digeriñ ar c'hannad-mañ",
|
||||||
"status.pin": "Spilhennañ d'ar profil",
|
"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.read_more": "Lenn muioc'h",
|
||||||
"status.reblog": "Skignañ",
|
"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.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.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.redraft": "Diverkañ ha skrivañ en-dro",
|
||||||
"status.remove_bookmark": "Dilemel ar sined",
|
"status.remove_bookmark": "Dilemel ar sined",
|
||||||
|
"status.remove_favourite": "Dilemel eus ar re vuiañ-karet",
|
||||||
"status.replied_to": "Respont da {name}",
|
"status.replied_to": "Respont da {name}",
|
||||||
"status.reply": "Respont",
|
"status.reply": "Respont",
|
||||||
"status.replyAll": "Respont d'ar gaozeadenn",
|
"status.replyAll": "Respont d'ar gaozeadenn",
|
||||||
@ -607,6 +713,7 @@
|
|||||||
"subscribed_languages.save": "Enrollañ ar cheñchamantoù",
|
"subscribed_languages.save": "Enrollañ ar cheñchamantoù",
|
||||||
"subscribed_languages.target": "Cheñch ar yezhoù koumanantet evit {target}",
|
"subscribed_languages.target": "Cheñch ar yezhoù koumanantet evit {target}",
|
||||||
"tabs_bar.home": "Degemer",
|
"tabs_bar.home": "Degemer",
|
||||||
|
"tabs_bar.menu": "Lañser",
|
||||||
"tabs_bar.notifications": "Kemennoù",
|
"tabs_bar.notifications": "Kemennoù",
|
||||||
"tabs_bar.publish": "Embannadenn nevez",
|
"tabs_bar.publish": "Embannadenn nevez",
|
||||||
"tabs_bar.search": "Klask",
|
"tabs_bar.search": "Klask",
|
||||||
@ -638,6 +745,10 @@
|
|||||||
"video.hide": "Kuzhat ar video",
|
"video.hide": "Kuzhat ar video",
|
||||||
"video.pause": "Paouez",
|
"video.pause": "Paouez",
|
||||||
"video.play": "Lenn",
|
"video.play": "Lenn",
|
||||||
|
"visibility_modal.privacy_label": "Gwelusted",
|
||||||
"visibility_modal.quote_followers": "Tud koumanantet hepken",
|
"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ñ"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -740,7 +740,6 @@
|
|||||||
"privacy.public.long": "Tothom dins o fora Mastodon",
|
"privacy.public.long": "Tothom dins o fora Mastodon",
|
||||||
"privacy.public.short": "Públic",
|
"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.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.unlisted.short": "Públic silenciós",
|
||||||
"privacy_policy.last_updated": "Darrera actualització {date}",
|
"privacy_policy.last_updated": "Darrera actualització {date}",
|
||||||
"privacy_policy.title": "Política de Privacitat",
|
"privacy_policy.title": "Política de Privacitat",
|
||||||
@ -900,7 +899,6 @@
|
|||||||
"status.read_more": "Més informació",
|
"status.read_more": "Més informació",
|
||||||
"status.reblog": "Impulsa",
|
"status.reblog": "Impulsa",
|
||||||
"status.reblog_or_quote": "Impuls or cita",
|
"status.reblog_or_quote": "Impuls or cita",
|
||||||
"status.reblog_private": "Impulsa amb la visibilitat original",
|
|
||||||
"status.reblogged_by": "impulsat per {name}",
|
"status.reblogged_by": "impulsat per {name}",
|
||||||
"status.reblogs": "{count, plural, one {impuls} other {impulsos}}",
|
"status.reblogs": "{count, plural, one {impuls} other {impulsos}}",
|
||||||
"status.reblogs.empty": "Encara no ha impulsat ningú aquest tut. Quan algú ho faci, apareixerà aquí.",
|
"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.button_title": "Establiu la visibilitat",
|
||||||
"visibility_modal.header": "Visibilitat i interacció",
|
"visibility_modal.header": "Visibilitat i interacció",
|
||||||
"visibility_modal.helper.direct_quoting": "No es poden citar mencions privades fetes a Mastondon.",
|
"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.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.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",
|
"visibility_modal.quote_followers": "Només seguidors",
|
||||||
|
|||||||
@ -496,7 +496,6 @@
|
|||||||
"status.pin": "لکاندن لەسەر پرۆفایل",
|
"status.pin": "لکاندن لەسەر پرۆفایل",
|
||||||
"status.read_more": "زیاتر بخوێنەوە",
|
"status.read_more": "زیاتر بخوێنەوە",
|
||||||
"status.reblog": "بەهێزکردن",
|
"status.reblog": "بەهێزکردن",
|
||||||
"status.reblog_private": "بەهێزکردن بۆ بینەرانی سەرەتایی",
|
|
||||||
"status.reblogged_by": "{name} توتی کردەوە",
|
"status.reblogged_by": "{name} توتی کردەوە",
|
||||||
"status.reblogs.empty": "کەس ئەم توتەی دووبارە نەتوتاندوە ،کاتێک کەسێک وا بکات، لێرە دەرئەکەون.",
|
"status.reblogs.empty": "کەس ئەم توتەی دووبارە نەتوتاندوە ،کاتێک کەسێک وا بکات، لێرە دەرئەکەون.",
|
||||||
"status.redraft": "سڕینەوەی و دووبارە ڕەشنووس",
|
"status.redraft": "سڕینەوەی و دووبارە ڕەشنووس",
|
||||||
|
|||||||
@ -288,7 +288,6 @@
|
|||||||
"status.pin": "Puntarulà à u prufile",
|
"status.pin": "Puntarulà à u prufile",
|
||||||
"status.read_more": "Leghje di più",
|
"status.read_more": "Leghje di più",
|
||||||
"status.reblog": "Sparte",
|
"status.reblog": "Sparte",
|
||||||
"status.reblog_private": "Sparte à l'audienza uriginale",
|
|
||||||
"status.reblogged_by": "{name} hà spartutu",
|
"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.reblogs.empty": "Per avà nisunu hà spartutu u statutu. Quandu qualch'unu u sparterà, u so contu sarà mustratu quì.",
|
||||||
"status.redraft": "Sguassà è riscrive",
|
"status.redraft": "Sguassà è riscrive",
|
||||||
|
|||||||
@ -239,6 +239,10 @@
|
|||||||
"confirmations.missing_alt_text.secondary": "Přesto odeslat",
|
"confirmations.missing_alt_text.secondary": "Přesto odeslat",
|
||||||
"confirmations.missing_alt_text.title": "Přidat popisek?",
|
"confirmations.missing_alt_text.title": "Přidat popisek?",
|
||||||
"confirmations.mute.confirm": "Skrýt",
|
"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.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.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?",
|
"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.disabled": "{visibility}, citování je zakázáno",
|
||||||
"privacy.quote.limited": "{visibility}, citování je omezené",
|
"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.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.unlisted.short": "Ztišené veřejné",
|
||||||
"privacy_policy.last_updated": "Naposledy aktualizováno {date}",
|
"privacy_policy.last_updated": "Naposledy aktualizováno {date}",
|
||||||
"privacy_policy.title": "Zásady ochrany osobních údajů",
|
"privacy_policy.title": "Zásady ochrany osobních údajů",
|
||||||
@ -768,6 +772,9 @@
|
|||||||
"relative_time.minutes": "{number} m",
|
"relative_time.minutes": "{number} m",
|
||||||
"relative_time.seconds": "{number} s",
|
"relative_time.seconds": "{number} s",
|
||||||
"relative_time.today": "dnes",
|
"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.attachments": "{count, plural, one {{counter} příloha} few {{counter} přílohy} other {{counter} příloh}}",
|
||||||
"reply_indicator.cancel": "Zrušit",
|
"reply_indicator.cancel": "Zrušit",
|
||||||
"reply_indicator.poll": "Anketa",
|
"reply_indicator.poll": "Anketa",
|
||||||
@ -863,6 +870,7 @@
|
|||||||
"status.block": "Blokovat @{name}",
|
"status.block": "Blokovat @{name}",
|
||||||
"status.bookmark": "Přidat do záložek",
|
"status.bookmark": "Přidat do záložek",
|
||||||
"status.cancel_reblog_private": "Zrušit boostnutí",
|
"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.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
||||||
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
|
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
|
||||||
"status.context.loading": "Hledání dalších odpovědí",
|
"status.context.loading": "Hledání dalších odpovědí",
|
||||||
@ -909,7 +917,7 @@
|
|||||||
"status.read_more": "Číst více",
|
"status.read_more": "Číst více",
|
||||||
"status.reblog": "Boostnout",
|
"status.reblog": "Boostnout",
|
||||||
"status.reblog_or_quote": "Boostnout nebo citovat",
|
"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.reblogged_by": "Uživatel {name} boostnul",
|
||||||
"status.reblogs": "{count, plural, one {boost} few {boosty} many {boostů} other {boostů}}",
|
"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.",
|
"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.button_title": "Nastavit viditelnost",
|
||||||
"visibility_modal.header": "Viditelnost a interakce",
|
"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.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.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.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>.",
|
"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>.",
|
||||||
|
|||||||
@ -745,7 +745,7 @@
|
|||||||
"privacy.quote.disabled": "{visibility}, dyfyniadau wedi'u hanalluogi",
|
"privacy.quote.disabled": "{visibility}, dyfyniadau wedi'u hanalluogi",
|
||||||
"privacy.quote.limited": "{visibility}, dyfyniadau wedi'u cyfyngu",
|
"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.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.unlisted.short": "Tewi'r cyhoeddus",
|
||||||
"privacy_policy.last_updated": "Diweddarwyd ddiwethaf ar {date}",
|
"privacy_policy.last_updated": "Diweddarwyd ddiwethaf ar {date}",
|
||||||
"privacy_policy.title": "Polisi Preifatrwydd",
|
"privacy_policy.title": "Polisi Preifatrwydd",
|
||||||
@ -768,6 +768,7 @@
|
|||||||
"relative_time.minutes": "{number} munud",
|
"relative_time.minutes": "{number} munud",
|
||||||
"relative_time.seconds": "{number} eiliad",
|
"relative_time.seconds": "{number} eiliad",
|
||||||
"relative_time.today": "heddiw",
|
"relative_time.today": "heddiw",
|
||||||
|
"remove_quote_hint.button_label": "Iawn",
|
||||||
"reply_indicator.attachments": "{count, plural, one {# atodiad} other {# atodiad}}",
|
"reply_indicator.attachments": "{count, plural, one {# atodiad} other {# atodiad}}",
|
||||||
"reply_indicator.cancel": "Diddymu",
|
"reply_indicator.cancel": "Diddymu",
|
||||||
"reply_indicator.poll": "Pleidlais",
|
"reply_indicator.poll": "Pleidlais",
|
||||||
@ -908,7 +909,6 @@
|
|||||||
"status.read_more": "Darllen rhagor",
|
"status.read_more": "Darllen rhagor",
|
||||||
"status.reblog": "Hybu",
|
"status.reblog": "Hybu",
|
||||||
"status.reblog_or_quote": "Hybu neu ddyfynnu",
|
"status.reblog_or_quote": "Hybu neu ddyfynnu",
|
||||||
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",
|
|
||||||
"status.reblogged_by": "Hybodd {name}",
|
"status.reblogged_by": "Hybodd {name}",
|
||||||
"status.reblogs": "{count, plural, one {# hwb} other {# hwb}}",
|
"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.",
|
"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.button_title": "Gosod gwelededd",
|
||||||
"visibility_modal.header": "Gwelededd a rhyngweithio",
|
"visibility_modal.header": "Gwelededd a rhyngweithio",
|
||||||
"visibility_modal.helper.direct_quoting": "Does dim modd dyfynnu crybwylliadau preifat ysgrifennwyd ar Mastodon.",
|
"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.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.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",
|
"visibility_modal.quote_followers": "Dilynwyr yn unig",
|
||||||
|
|||||||
@ -239,6 +239,10 @@
|
|||||||
"confirmations.missing_alt_text.secondary": "Læg op alligevel",
|
"confirmations.missing_alt_text.secondary": "Læg op alligevel",
|
||||||
"confirmations.missing_alt_text.title": "Tilføj alt-tekst?",
|
"confirmations.missing_alt_text.title": "Tilføj alt-tekst?",
|
||||||
"confirmations.mute.confirm": "Skjul",
|
"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.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.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?",
|
"confirmations.redraft.title": "Slet og omskriv indlæg?",
|
||||||
@ -745,7 +749,7 @@
|
|||||||
"privacy.quote.disabled": "{visibility}, citering deaktiveret",
|
"privacy.quote.disabled": "{visibility}, citering deaktiveret",
|
||||||
"privacy.quote.limited": "{visibility}, citering begrænset",
|
"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.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.unlisted.short": "Offentlig (stille)",
|
||||||
"privacy_policy.last_updated": "Senest opdateret {date}",
|
"privacy_policy.last_updated": "Senest opdateret {date}",
|
||||||
"privacy_policy.title": "Privatlivspolitik",
|
"privacy_policy.title": "Privatlivspolitik",
|
||||||
@ -768,6 +772,9 @@
|
|||||||
"relative_time.minutes": "{number}m",
|
"relative_time.minutes": "{number}m",
|
||||||
"relative_time.seconds": "{number}s",
|
"relative_time.seconds": "{number}s",
|
||||||
"relative_time.today": "i dag",
|
"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.attachments": "{count, plural, one {# vedhæftning} other {# vedhæftninger}}",
|
||||||
"reply_indicator.cancel": "Afbryd",
|
"reply_indicator.cancel": "Afbryd",
|
||||||
"reply_indicator.poll": "Afstemning",
|
"reply_indicator.poll": "Afstemning",
|
||||||
@ -910,7 +917,7 @@
|
|||||||
"status.read_more": "Læs mere",
|
"status.read_more": "Læs mere",
|
||||||
"status.reblog": "Fremhæv",
|
"status.reblog": "Fremhæv",
|
||||||
"status.reblog_or_quote": "Fremhæv eller citér",
|
"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.reblogged_by": "{name} fremhævede",
|
||||||
"status.reblogs": "{count, plural, one {# fremhævelse} other {# fremhævelser}}",
|
"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.",
|
"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.button_title": "Indstil synlighed",
|
||||||
"visibility_modal.header": "Synlighed og interaktion",
|
"visibility_modal.header": "Synlighed og interaktion",
|
||||||
"visibility_modal.helper.direct_quoting": "Private omtaler forfattet på Mastodon kan ikke citeres af andre.",
|
"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.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.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.",
|
"visibility_modal.helper.unlisted_quoting": "Når folk citerer dig, vil deres indlæg også blive skjult fra trendtidslinjer.",
|
||||||
|
|||||||
@ -239,6 +239,10 @@
|
|||||||
"confirmations.missing_alt_text.secondary": "Trotzdem veröffentlichen",
|
"confirmations.missing_alt_text.secondary": "Trotzdem veröffentlichen",
|
||||||
"confirmations.missing_alt_text.title": "Bildbeschreibung hinzufügen?",
|
"confirmations.missing_alt_text.title": "Bildbeschreibung hinzufügen?",
|
||||||
"confirmations.mute.confirm": "Stummschalten",
|
"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.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.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?",
|
"confirmations.redraft.title": "Beitrag löschen und neu verfassen?",
|
||||||
@ -745,7 +749,7 @@
|
|||||||
"privacy.quote.disabled": "{visibility} – niemand darf zitieren",
|
"privacy.quote.disabled": "{visibility} – niemand darf zitieren",
|
||||||
"privacy.quote.limited": "{visibility} – eingeschränktes 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.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.unlisted.short": "Öffentlich (still)",
|
||||||
"privacy_policy.last_updated": "Stand: {date}",
|
"privacy_policy.last_updated": "Stand: {date}",
|
||||||
"privacy_policy.title": "Datenschutzerklärung",
|
"privacy_policy.title": "Datenschutzerklärung",
|
||||||
@ -768,6 +772,9 @@
|
|||||||
"relative_time.minutes": "{number} Min.",
|
"relative_time.minutes": "{number} Min.",
|
||||||
"relative_time.seconds": "{number} Sek.",
|
"relative_time.seconds": "{number} Sek.",
|
||||||
"relative_time.today": "heute",
|
"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.attachments": "{count, plural, one {# Anhang} other {# Anhänge}}",
|
||||||
"reply_indicator.cancel": "Abbrechen",
|
"reply_indicator.cancel": "Abbrechen",
|
||||||
"reply_indicator.poll": "Umfrage",
|
"reply_indicator.poll": "Umfrage",
|
||||||
@ -910,7 +917,7 @@
|
|||||||
"status.read_more": "Gesamten Beitrag anschauen",
|
"status.read_more": "Gesamten Beitrag anschauen",
|
||||||
"status.reblog": "Teilen",
|
"status.reblog": "Teilen",
|
||||||
"status.reblog_or_quote": "Teilen oder zitieren",
|
"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.reblogged_by": "{name} teilte",
|
||||||
"status.reblogs": "{count, plural, one {Mal geteilt} other {Mal geteilt}}",
|
"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.",
|
"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.button_title": "Sichtbarkeit festlegen",
|
||||||
"visibility_modal.header": "Sichtbarkeit und Interaktion",
|
"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.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.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.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.",
|
"visibility_modal.helper.unlisted_quoting": "Sollten dich andere zitieren, werden ihre zitierten Beiträge ebenfalls nicht in den Trends und öffentlichen Timelines angezeigt.",
|
||||||
|
|||||||
@ -239,6 +239,8 @@
|
|||||||
"confirmations.missing_alt_text.secondary": "Δημοσίευση όπως και να ΄χει",
|
"confirmations.missing_alt_text.secondary": "Δημοσίευση όπως και να ΄χει",
|
||||||
"confirmations.missing_alt_text.title": "Προσθήκη εναλλακτικού κειμένου;",
|
"confirmations.missing_alt_text.title": "Προσθήκη εναλλακτικού κειμένου;",
|
||||||
"confirmations.mute.confirm": "Αποσιώπηση",
|
"confirmations.mute.confirm": "Αποσιώπηση",
|
||||||
|
"confirmations.quiet_post_quote_info.dismiss": "Μη μου το ξαναθυμίσεις",
|
||||||
|
"confirmations.quiet_post_quote_info.got_it": "Το κατάλαβα",
|
||||||
"confirmations.redraft.confirm": "Διαγραφή & ξαναγράψιμο",
|
"confirmations.redraft.confirm": "Διαγραφή & ξαναγράψιμο",
|
||||||
"confirmations.redraft.message": "Σίγουρα θέλεις να σβήσεις αυτή την ανάρτηση και να την ξαναγράψεις; Οι προτιμήσεις και προωθήσεις θα χαθούν και οι απαντήσεις στην αρχική ανάρτηση θα μείνουν ορφανές.",
|
"confirmations.redraft.message": "Σίγουρα θέλεις να σβήσεις αυτή την ανάρτηση και να την ξαναγράψεις; Οι προτιμήσεις και προωθήσεις θα χαθούν και οι απαντήσεις στην αρχική ανάρτηση θα μείνουν ορφανές.",
|
||||||
"confirmations.redraft.title": "Διαγραφή & επανασύνταξη;",
|
"confirmations.redraft.title": "Διαγραφή & επανασύνταξη;",
|
||||||
@ -492,6 +494,7 @@
|
|||||||
"keyboard_shortcuts.open_media": "Άνοιγμα πολυμέσων",
|
"keyboard_shortcuts.open_media": "Άνοιγμα πολυμέσων",
|
||||||
"keyboard_shortcuts.pinned": "Άνοιγμα λίστας καρφιτσωμένων αναρτήσεων",
|
"keyboard_shortcuts.pinned": "Άνοιγμα λίστας καρφιτσωμένων αναρτήσεων",
|
||||||
"keyboard_shortcuts.profile": "Άνοιγμα προφίλ συγγραφέα",
|
"keyboard_shortcuts.profile": "Άνοιγμα προφίλ συγγραφέα",
|
||||||
|
"keyboard_shortcuts.quote": "Παράθεση ανάρτησης",
|
||||||
"keyboard_shortcuts.reply": "Απάντηση στην ανάρτηση",
|
"keyboard_shortcuts.reply": "Απάντηση στην ανάρτηση",
|
||||||
"keyboard_shortcuts.requests": "Άνοιγμα λίστας αιτημάτων ακολούθησης",
|
"keyboard_shortcuts.requests": "Άνοιγμα λίστας αιτημάτων ακολούθησης",
|
||||||
"keyboard_shortcuts.search": "Εστίαση στη γραμμή αναζήτησης",
|
"keyboard_shortcuts.search": "Εστίαση στη γραμμή αναζήτησης",
|
||||||
@ -620,6 +623,7 @@
|
|||||||
"notification.moderation_warning.action_suspend": "Ο λογαριασμός σου έχει ανασταλεί.",
|
"notification.moderation_warning.action_suspend": "Ο λογαριασμός σου έχει ανασταλεί.",
|
||||||
"notification.own_poll": "Η δημοσκόπησή σου έληξε",
|
"notification.own_poll": "Η δημοσκόπησή σου έληξε",
|
||||||
"notification.poll": "Μία ψηφοφορία στην οποία συμμετείχες έχει τελειώσει",
|
"notification.poll": "Μία ψηφοφορία στην οποία συμμετείχες έχει τελειώσει",
|
||||||
|
"notification.quoted_update": "Ο χρήστης {name} επεξεργάστηκε μία ανάρτηση που παρέθεσες",
|
||||||
"notification.reblog": "Ο/Η {name} ενίσχυσε την ανάρτηση σου",
|
"notification.reblog": "Ο/Η {name} ενίσχυσε την ανάρτηση σου",
|
||||||
"notification.reblog.name_and_others_with_link": "{name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> ενίσχυσαν την ανάρτησή σου",
|
"notification.reblog.name_and_others_with_link": "{name} και <a>{count, plural, one {# ακόμη} other {# ακόμη}}</a> ενίσχυσαν την ανάρτησή σου",
|
||||||
"notification.relationships_severance_event": "Χάθηκε η σύνδεση με το {name}",
|
"notification.relationships_severance_event": "Χάθηκε η σύνδεση με το {name}",
|
||||||
@ -743,7 +747,7 @@
|
|||||||
"privacy.quote.disabled": "{visibility}, παραθέσεις απενεργοποιημένες",
|
"privacy.quote.disabled": "{visibility}, παραθέσεις απενεργοποιημένες",
|
||||||
"privacy.quote.limited": "{visibility}, παραθέσεις περιορισμένες",
|
"privacy.quote.limited": "{visibility}, παραθέσεις περιορισμένες",
|
||||||
"privacy.unlisted.additional": "Αυτό συμπεριφέρεται ακριβώς όπως το δημόσιο, εκτός από το ότι η ανάρτηση δεν θα εμφανιστεί σε ζωντανές ροές ή ετικέτες, εξερεύνηση ή αναζήτηση στο Mastodon, ακόμη και αν το έχεις επιλέξει για τον λογαριασμό σου.",
|
"privacy.unlisted.additional": "Αυτό συμπεριφέρεται ακριβώς όπως το δημόσιο, εκτός από το ότι η ανάρτηση δεν θα εμφανιστεί σε ζωντανές ροές ή ετικέτες, εξερεύνηση ή αναζήτηση στο Mastodon, ακόμη και αν το έχεις επιλέξει για τον λογαριασμό σου.",
|
||||||
"privacy.unlisted.long": "Λιγότερα αλγοριθμικά κόλπα",
|
"privacy.unlisted.long": "Κρυμμένο από τα αποτελέσματα αναζήτησης Mastodon, τις τάσεις και τις δημόσιες ροές",
|
||||||
"privacy.unlisted.short": "Ήσυχα δημόσια",
|
"privacy.unlisted.short": "Ήσυχα δημόσια",
|
||||||
"privacy_policy.last_updated": "Τελευταία ενημέρωση {date}",
|
"privacy_policy.last_updated": "Τελευταία ενημέρωση {date}",
|
||||||
"privacy_policy.title": "Πολιτική Απορρήτου",
|
"privacy_policy.title": "Πολιτική Απορρήτου",
|
||||||
@ -766,6 +770,9 @@
|
|||||||
"relative_time.minutes": "{number}λ",
|
"relative_time.minutes": "{number}λ",
|
||||||
"relative_time.seconds": "{number}δ",
|
"relative_time.seconds": "{number}δ",
|
||||||
"relative_time.today": "σήμερα",
|
"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.attachments": "{count, plural, one {# συνημμένο} other {# συνημμένα}}",
|
||||||
"reply_indicator.cancel": "Άκυρο",
|
"reply_indicator.cancel": "Άκυρο",
|
||||||
"reply_indicator.poll": "Δημοσκόπηση",
|
"reply_indicator.poll": "Δημοσκόπηση",
|
||||||
@ -861,12 +868,14 @@
|
|||||||
"status.block": "Αποκλεισμός @{name}",
|
"status.block": "Αποκλεισμός @{name}",
|
||||||
"status.bookmark": "Σελιδοδείκτης",
|
"status.bookmark": "Σελιδοδείκτης",
|
||||||
"status.cancel_reblog_private": "Ακύρωση ενίσχυσης",
|
"status.cancel_reblog_private": "Ακύρωση ενίσχυσης",
|
||||||
|
"status.cannot_quote": "Οι παραθέσεις είναι ανενεργές σ' αυτήν την ανάρτηση",
|
||||||
"status.cannot_reblog": "Αυτή η ανάρτηση δεν μπορεί να ενισχυθεί",
|
"status.cannot_reblog": "Αυτή η ανάρτηση δεν μπορεί να ενισχυθεί",
|
||||||
"status.context.load_new_replies": "Νέες απαντήσεις διαθέσιμες",
|
"status.context.load_new_replies": "Νέες απαντήσεις διαθέσιμες",
|
||||||
"status.context.loading": "Γίνεται έλεγχος για περισσότερες απαντήσεις",
|
"status.context.loading": "Γίνεται έλεγχος για περισσότερες απαντήσεις",
|
||||||
"status.continued_thread": "Συνεχιζόμενο νήματος",
|
"status.continued_thread": "Συνεχιζόμενο νήματος",
|
||||||
"status.copy": "Αντιγραφή συνδέσμου ανάρτησης",
|
"status.copy": "Αντιγραφή συνδέσμου ανάρτησης",
|
||||||
"status.delete": "Διαγραφή",
|
"status.delete": "Διαγραφή",
|
||||||
|
"status.delete.success": "Η ανάρτηση διαγράφηκε",
|
||||||
"status.detailed_status": "Προβολή λεπτομερούς συζήτησης",
|
"status.detailed_status": "Προβολή λεπτομερούς συζήτησης",
|
||||||
"status.direct": "Ιδιωτική επισήμανση @{name}",
|
"status.direct": "Ιδιωτική επισήμανση @{name}",
|
||||||
"status.direct_indicator": "Ιδιωτική επισήμανση",
|
"status.direct_indicator": "Ιδιωτική επισήμανση",
|
||||||
@ -896,12 +905,17 @@
|
|||||||
"status.quote_error.pending_approval": "Ανάρτηση σε αναμονή",
|
"status.quote_error.pending_approval": "Ανάρτηση σε αναμονή",
|
||||||
"status.quote_error.pending_approval_popout.body": "Οι παραθέσεις που μοιράζονται στο Fediverse μπορεί να χρειαστούν χρόνο για να εμφανιστούν, καθώς διαφορετικοί διακομιστές έχουν διαφορετικά πρωτόκολλα.",
|
"status.quote_error.pending_approval_popout.body": "Οι παραθέσεις που μοιράζονται στο Fediverse μπορεί να χρειαστούν χρόνο για να εμφανιστούν, καθώς διαφορετικοί διακομιστές έχουν διαφορετικά πρωτόκολλα.",
|
||||||
"status.quote_error.pending_approval_popout.title": "Παράθεση σε εκκρεμότητα; Μείνετε ψύχραιμοι",
|
"status.quote_error.pending_approval_popout.title": "Παράθεση σε εκκρεμότητα; Μείνετε ψύχραιμοι",
|
||||||
|
"status.quote_followers_only": "Μόνο οι ακόλουθοι μπορούν να παραθέσουν αυτή την ανάρτηση",
|
||||||
|
"status.quote_manual_review": "Ο συντάκτης θα επανεξετάσει χειροκίνητα",
|
||||||
"status.quote_policy_change": "Αλλάξτε ποιός μπορεί να κάνει παράθεση",
|
"status.quote_policy_change": "Αλλάξτε ποιός μπορεί να κάνει παράθεση",
|
||||||
"status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}",
|
"status.quote_post_author": "Παρατίθεται μια ανάρτηση από @{name}",
|
||||||
"status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν",
|
"status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν",
|
||||||
|
"status.quotes": "{count, plural, one {# παράθεση} other {# παραθέσεις}}",
|
||||||
|
"status.quotes.empty": "Κανείς δεν έχει παραθέσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει, θα εμφανιστεί εδώ.",
|
||||||
"status.read_more": "Διάβασε περισότερα",
|
"status.read_more": "Διάβασε περισότερα",
|
||||||
"status.reblog": "Ενίσχυση",
|
"status.reblog": "Ενίσχυση",
|
||||||
"status.reblog_private": "Ενίσχυση με αρχική ορατότητα",
|
"status.reblog_or_quote": "Ενίσχυση ή παράθεση",
|
||||||
|
"status.reblog_private": "Μοιράσου ξανά με τους ακόλουθούς σου",
|
||||||
"status.reblogged_by": "{name} προώθησε",
|
"status.reblogged_by": "{name} προώθησε",
|
||||||
"status.reblogs": "{count, plural, one {ενίσχυση} other {ενισχύσεις}}",
|
"status.reblogs": "{count, plural, one {ενίσχυση} other {ενισχύσεις}}",
|
||||||
"status.reblogs.empty": "Κανείς δεν ενίσχυσε αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
|
"status.reblogs.empty": "Κανείς δεν ενίσχυσε αυτή την ανάρτηση ακόμα. Μόλις το κάνει κάποιος, θα εμφανιστεί εδώ.",
|
||||||
@ -913,6 +927,7 @@
|
|||||||
"status.reply": "Απάντησε",
|
"status.reply": "Απάντησε",
|
||||||
"status.replyAll": "Απάντησε στο νήμα συζήτησης",
|
"status.replyAll": "Απάντησε στο νήμα συζήτησης",
|
||||||
"status.report": "Αναφορά @{name}",
|
"status.report": "Αναφορά @{name}",
|
||||||
|
"status.request_quote": "Αίτημα για παράθεση",
|
||||||
"status.revoke_quote": "Αφαίρεση της ανάρτησης μου από την ανάρτηση του/της @{name}",
|
"status.revoke_quote": "Αφαίρεση της ανάρτησης μου από την ανάρτηση του/της @{name}",
|
||||||
"status.sensitive_warning": "Ευαίσθητο περιεχόμενο",
|
"status.sensitive_warning": "Ευαίσθητο περιεχόμενο",
|
||||||
"status.share": "Κοινοποίηση",
|
"status.share": "Κοινοποίηση",
|
||||||
@ -977,9 +992,14 @@
|
|||||||
"video.volume_up": "Αύξηση έντασης",
|
"video.volume_up": "Αύξηση έντασης",
|
||||||
"visibility_modal.button_title": "Ορισμός ορατότητας",
|
"visibility_modal.button_title": "Ορισμός ορατότητας",
|
||||||
"visibility_modal.header": "Ορατότητα και αλληλεπίδραση",
|
"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.helper.unlisted_quoting": "Όταν οι άνθρωποι σας παραθέτουν, η ανάρτηση τους θα είναι επίσης κρυμμένη από τα δημοφιλή χρονοδιαγράμματα.",
|
||||||
|
"visibility_modal.privacy_label": "Ορατότητα",
|
||||||
"visibility_modal.quote_followers": "Μόνο ακόλουθοι",
|
"visibility_modal.quote_followers": "Μόνο ακόλουθοι",
|
||||||
|
"visibility_modal.quote_label": "Ποιος μπορεί να παραθέσει",
|
||||||
|
"visibility_modal.quote_nobody": "Μόνο εγώ",
|
||||||
"visibility_modal.quote_public": "Οποιοσδήποτε",
|
"visibility_modal.quote_public": "Οποιοσδήποτε",
|
||||||
"visibility_modal.save": "Αποθήκευση"
|
"visibility_modal.save": "Αποθήκευση"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -731,7 +731,6 @@
|
|||||||
"privacy.public.long": "Anyone on and off Mastodon",
|
"privacy.public.long": "Anyone on and off Mastodon",
|
||||||
"privacy.public.short": "Public",
|
"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.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.unlisted.short": "Quiet public",
|
||||||
"privacy_policy.last_updated": "Last updated {date}",
|
"privacy_policy.last_updated": "Last updated {date}",
|
||||||
"privacy_policy.title": "Privacy Policy",
|
"privacy_policy.title": "Privacy Policy",
|
||||||
@ -873,7 +872,6 @@
|
|||||||
"status.quote_error.filtered": "Hidden due to one of your filters",
|
"status.quote_error.filtered": "Hidden due to one of your filters",
|
||||||
"status.read_more": "Read more",
|
"status.read_more": "Read more",
|
||||||
"status.reblog": "Boost",
|
"status.reblog": "Boost",
|
||||||
"status.reblog_private": "Boost with original visibility",
|
|
||||||
"status.reblogged_by": "{name} boosted",
|
"status.reblogged_by": "{name} boosted",
|
||||||
"status.reblogs": "{count, plural, one {boost} other {boosts}}",
|
"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.",
|
"status.reblogs.empty": "No one has boosted this post yet. When someone does, they will show up here.",
|
||||||
|
|||||||
@ -239,6 +239,10 @@
|
|||||||
"confirmations.missing_alt_text.secondary": "Post anyway",
|
"confirmations.missing_alt_text.secondary": "Post anyway",
|
||||||
"confirmations.missing_alt_text.title": "Add alt text?",
|
"confirmations.missing_alt_text.title": "Add alt text?",
|
||||||
"confirmations.mute.confirm": "Mute",
|
"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.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.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?",
|
"confirmations.redraft.title": "Delete & redraft post?",
|
||||||
@ -745,7 +749,7 @@
|
|||||||
"privacy.quote.disabled": "{visibility}, quotes disabled",
|
"privacy.quote.disabled": "{visibility}, quotes disabled",
|
||||||
"privacy.quote.limited": "{visibility}, quotes limited",
|
"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.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.unlisted.short": "Quiet public",
|
||||||
"privacy_policy.last_updated": "Last updated {date}",
|
"privacy_policy.last_updated": "Last updated {date}",
|
||||||
"privacy_policy.title": "Privacy Policy",
|
"privacy_policy.title": "Privacy Policy",
|
||||||
@ -768,6 +772,9 @@
|
|||||||
"relative_time.minutes": "{number}m",
|
"relative_time.minutes": "{number}m",
|
||||||
"relative_time.seconds": "{number}s",
|
"relative_time.seconds": "{number}s",
|
||||||
"relative_time.today": "today",
|
"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.attachments": "{count, plural, one {# attachment} other {# attachments}}",
|
||||||
"reply_indicator.cancel": "Cancel",
|
"reply_indicator.cancel": "Cancel",
|
||||||
"reply_indicator.poll": "Poll",
|
"reply_indicator.poll": "Poll",
|
||||||
@ -910,7 +917,7 @@
|
|||||||
"status.read_more": "Read more",
|
"status.read_more": "Read more",
|
||||||
"status.reblog": "Boost",
|
"status.reblog": "Boost",
|
||||||
"status.reblog_or_quote": "Boost or quote",
|
"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.reblogged_by": "{name} boosted",
|
||||||
"status.reblogs": "{count, plural, one {boost} other {boosts}}",
|
"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.",
|
"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.button_title": "Set visibility",
|
||||||
"visibility_modal.header": "Visibility and interaction",
|
"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.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.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.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.",
|
"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
Loading…
x
Reference in New Issue
Block a user