Merge commit 'f69ca085dbfca2253404574dcdc4dc6c2aaa35c0' into glitch-soc/merge-upstream

This commit is contained in:
Claire
2025-10-01 19:13:18 +02:00
147 changed files with 1188 additions and 808 deletions

View File

@@ -4,13 +4,13 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
before_action :check_owner!
before_action :set_statuses, only: :index
before_action :set_quote, only: :revoke
after_action :insert_pagination_headers, only: :index
def index
cache_if_unauthenticated!
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer
end
@@ -24,18 +24,26 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
private
def check_owner!
authorize @status, :list_quotes?
end
def set_quote
@quote = @status.quotes.find_by!(status_id: params[:id])
end
def load_statuses
def set_statuses
scope = default_statuses
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
scope.merge(paginated_quotes).to_a
@statuses = scope.merge(paginated_quotes).to_a
# Store next page info before filtering
@records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
@pagination_since_id = @statuses.first.quote.id unless @statuses.empty?
@pagination_max_id = @statuses.last.quote.id if @records_continue
if current_account&.id != @status.account_id
domains = @statuses.filter_map(&:account_domain).uniq
account_ids = @statuses.map(&:account_id).uniq
relations = current_account&.relations_map(account_ids, domains) || {}
@statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? }
end
end
def default_statuses
@@ -58,15 +66,9 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end
def pagination_max_id
@statuses.last.quote.id
end
def pagination_since_id
@statuses.first.quote.id
end
attr_reader :pagination_max_id, :pagination_since_id
def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
@records_continue
end
end

View File

@@ -1,11 +1,15 @@
import { useCallback } from 'react';
import classNames from 'classnames';
import { useLinks } from 'mastodon/hooks/useLinks';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { useAppSelector } from '../store';
import { isModernEmojiEnabled } from '../utils/environment';
import { AnimateEmojiProvider } from './emoji/context';
import { EmojiHTML } from './emoji/html';
interface AccountBioProps {
className: string;
accountId: string;
@@ -44,13 +48,13 @@ export const AccountBio: React.FC<AccountBioProps> = ({
}
return (
<div
className={`${className} translate`}
<AnimateEmojiProvider
className={classNames(className, 'translate')}
onClickCapture={handleClick}
ref={handleNodeChange}
>
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
</div>
</AnimateEmojiProvider>
);
};

View File

@@ -2,9 +2,10 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
import classNames from 'classnames';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { AnimateEmojiProvider } from '../emoji/context';
import { EmojiHTML } from '../emoji/html';
import { Skeleton } from '../skeleton';
import type { DisplayNameProps } from './index';
@@ -14,9 +15,10 @@ export const DisplayNameWithoutDomain: FC<
ComponentPropsWithoutRef<'span'>
> = ({ account, className, children, ...props }) => {
return (
<span
<AnimateEmojiProvider
{...props}
className={classNames('display-name animate-parent', className)}
as='span'
className={classNames('display-name', className)}
>
<bdi>
{account ? (
@@ -27,8 +29,8 @@ export const DisplayNameWithoutDomain: FC<
? account.get('display_name')
: account.get('display_name_html')
}
shallow
as='strong'
extraEmojis={account.get('emojis')}
/>
) : (
<strong className='display-name__html'>
@@ -37,6 +39,6 @@ export const DisplayNameWithoutDomain: FC<
)}
</bdi>
{children}
</span>
</AnimateEmojiProvider>
);
};

View File

@@ -1,8 +1,9 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { EmojiHTML } from '../emoji/html';
import type { DisplayNameProps } from './index';
export const DisplayNameSimple: FC<
@@ -12,12 +13,19 @@ export const DisplayNameSimple: FC<
if (!account) {
return null;
}
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
return (
<bdi>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
<EmojiHTML
{...props}
as='span'
htmlString={
isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html')
}
extraEmojis={account.get('emojis')}
/>
</bdi>
);
};

View File

@@ -0,0 +1,108 @@
import type { MouseEventHandler, PropsWithChildren } from 'react';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import classNames from 'classnames';
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
import { autoPlayGif } from '@/mastodon/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
} from 'mastodon/features/emoji/types';
// Animation context
export const AnimateEmojiContext = createContext<boolean | null>(null);
// Polymorphic provider component
type AnimateEmojiProviderProps = Required<PropsWithChildren> & {
className?: string;
};
export const AnimateEmojiProvider = polymorphicForwardRef<
'div',
AnimateEmojiProviderProps
>(
(
{
children,
as: Wrapper = 'div',
className,
onMouseEnter,
onMouseLeave,
...props
},
ref,
) => {
const [animate, setAnimate] = useState(autoPlayGif ?? false);
const handleEnter: MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
onMouseEnter?.(event);
if (!autoPlayGif) {
setAnimate(true);
}
},
[onMouseEnter],
);
const handleLeave: MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
onMouseLeave?.(event);
if (!autoPlayGif) {
setAnimate(false);
}
},
[onMouseLeave],
);
// If there's a parent context or GIFs autoplay, we don't need handlers.
const parentContext = useContext(AnimateEmojiContext);
if (parentContext !== null || autoPlayGif === true) {
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
ref={ref}
>
{children}
</Wrapper>
);
}
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
ref={ref}
>
<AnimateEmojiContext.Provider value={animate}>
{children}
</AnimateEmojiContext.Provider>
</Wrapper>
);
},
);
AnimateEmojiProvider.displayName = 'AnimateEmojiProvider';
// Handle custom emoji
export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
export const CustomEmojiProvider = ({
children,
emojis: rawEmojis,
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
return (
<CustomEmojiContext.Provider value={emojis}>
{children}
</CustomEmojiContext.Provider>
);
};

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import classNames from 'classnames';
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { htmlStringToComponents } from '@/mastodon/utils/html';
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
import { textToEmojis } from './index';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML' | 'className'
> & {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
className?: string;
};
export const ModernEmojiHTML = ({
extraEmojis,
htmlString,
as: asProp = 'div', // Rename for syntax highlighting
shallow,
className = '',
...props
}: EmojiHTMLProps<ElementType>) => {
const contents = useMemo(
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
[htmlString],
);
return (
<CustomEmojiProvider emojis={extraEmojis}>
<AnimateEmojiProvider {...props} as={asProp} className={className}>
{contents}
</AnimateEmojiProvider>
</CustomEmojiProvider>
);
};
export const LegacyEmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
};
export const EmojiHTML = isModernEmojiEnabled()
? ModernEmojiHTML
: LegacyEmojiHTML;

View File

@@ -0,0 +1,99 @@
import type { FC } from 'react';
import { useContext, useEffect, useState } from 'react';
import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants';
import { useEmojiAppState } from '@/mastodon/features/emoji/hooks';
import { unicodeHexToUrl } from '@/mastodon/features/emoji/normalize';
import {
isStateLoaded,
loadEmojiDataToState,
shouldRenderImage,
stringToEmojiState,
tokenizeText,
} from '@/mastodon/features/emoji/render';
import { AnimateEmojiContext, CustomEmojiContext } from './context';
interface EmojiProps {
code: string;
showFallback?: boolean;
showLoading?: boolean;
}
export const Emoji: FC<EmojiProps> = ({
code,
showFallback = true,
showLoading = true,
}) => {
const customEmoji = useContext(CustomEmojiContext);
// First, set the emoji state based on the input code.
const [state, setState] = useState(() =>
stringToEmojiState(code, customEmoji),
);
// If we don't have data, then load emoji data asynchronously.
const appState = useEmojiAppState();
useEffect(() => {
if (state !== null) {
void loadEmojiDataToState(state, appState.currentLocale).then(setState);
}
}, [appState.currentLocale, state]);
const animate = useContext(AnimateEmojiContext);
const fallback = showFallback ? code : null;
// If the code is invalid or we otherwise know it's not valid, show the fallback.
if (!state) {
return fallback;
}
if (!shouldRenderImage(state, appState.mode)) {
return code;
}
if (!isStateLoaded(state)) {
if (showLoading) {
return <span className='emojione emoji-loading' title={code} />;
}
return fallback;
}
if (state.type === EMOJI_TYPE_CUSTOM) {
const shortcode = `:${state.code}:`;
return (
<img
src={animate ? state.data.url : state.data.static_url}
alt={shortcode}
title={shortcode}
className='emojione custom-emoji'
loading='lazy'
/>
);
}
const src = unicodeHexToUrl(state.code, appState.darkTheme);
return (
<img
src={src}
alt={state.data.unicode}
title={state.data.label}
className='emojione'
loading='lazy'
/>
);
};
/**
* Takes a text string and converts it to an array of React nodes.
* @param text The text to be tokenized and converted.
*/
export function textToEmojis(text: string) {
return tokenizeText(text).map((token, index) => {
if (typeof token === 'string') {
return token;
}
return <Emoji code={token.code} key={`emoji-${token.code}-${index}`} />;
});
}

View File

@@ -8,7 +8,6 @@ import { useIdentity } from '@/mastodon/identity_context';
import {
fetchRelationships,
followAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
@@ -59,7 +58,8 @@ export const FollowButton: React.FC<{
accountId?: string;
compact?: boolean;
labelLength?: 'auto' | 'short' | 'long';
}> = ({ accountId, compact, labelLength = 'auto' }) => {
className?: string;
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@@ -96,12 +96,24 @@ export const FollowButton: React.FC<{
return;
} else if (relationship.muting) {
dispatch(unmuteAccount(accountId));
} else if (account && (relationship.following || relationship.requested)) {
} else if (account && relationship.following) {
dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
);
} else if (account && relationship.requested) {
dispatch(
openModal({
modalType: 'CONFIRM_WITHDRAW_REQUEST',
modalProps: { account },
}),
);
} else if (relationship.blocking) {
dispatch(unblockAccount(accountId));
dispatch(
openModal({
modalType: 'CONFIRM_UNBLOCK',
modalProps: { account },
}),
);
} else {
dispatch(followAccount(accountId));
}
@@ -144,7 +156,7 @@ export const FollowButton: React.FC<{
href='/settings/profile'
target='_blank'
rel='noopener'
className={classNames('button button-secondary', {
className={classNames(className, 'button button-secondary', {
'button--compact': compact,
})}
>
@@ -158,13 +170,12 @@ export const FollowButton: React.FC<{
onClick={handleClick}
disabled={
relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved))
}
secondary={following}
compact={compact}
className={following ? 'button--destructive' : undefined}
className={classNames(className, { 'button--destructive': following })}
>
{label}
</Button>

View File

@@ -13,10 +13,12 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
import { Icon } from 'mastodon/components/icon';
import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
/**

View File

@@ -5,7 +5,7 @@ import { fetchServer } from 'mastodon/actions/server';
import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import Compose from 'mastodon/features/standalone/compose';
import initialState from 'mastodon/initial_state';
import { initialState } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store';

View File

@@ -13,7 +13,7 @@ import ErrorBoundary from 'mastodon/components/error_boundary';
import { Router } from 'mastodon/components/router';
import UI from 'mastodon/features/ui';
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { initialState, title as siteTitle } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store';
import { isProduction } from 'mastodon/utils/environment';

View File

@@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@@ -33,7 +34,6 @@ import { initMuteModal } from 'mastodon/actions/mutes';
import { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
import { Button } from 'mastodon/components/button';
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import {
FollowersCounter,
@@ -383,7 +383,7 @@ export const AccountHeader: React.FC<{
const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => {
const menuItems = useMemo(() => {
const arr: MenuItem[] = [];
if (!account) {
@@ -605,6 +605,15 @@ export const AccountHeader: React.FC<{
handleUnblockDomain,
]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) {
return null;
}
@@ -718,21 +727,16 @@ export const AccountHeader: React.FC<{
);
}
if (relationship?.blocking) {
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock, {
name: account.username,
})}
onClick={handleBlock}
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
);
} else {
actionBtn = <FollowButton accountId={accountId} />;
}
if (account.moved && !relationship?.following) {
actionBtn = '';
}
if (account.locked) {
@@ -777,8 +781,8 @@ export const AccountHeader: React.FC<{
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<div
className={classNames('account__header animate-parent', {
<AnimateEmojiProvider
className={classNames('account__header', {
inactive: !!account.moved,
})}
>
@@ -814,18 +818,11 @@ export const AccountHeader: React.FC<{
/>
</a>
<div className='account__header__tabs__buttons'>
<div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{!hidden && shareBtn}
{accountId !== me && (
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
)}
{!hidden && actionBtn}
{menu}
</div>
</div>
@@ -855,6 +852,12 @@ export const AccountHeader: React.FC<{
<FamiliarFollowers accountId={accountId} />
)}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div
@@ -967,7 +970,7 @@ export const AccountHeader: React.FC<{
</div>
)}
</div>
</div>
</AnimateEmojiProvider>
{!(hideTabs || hidden) && (
<div className='account__section-headline'>

View File

@@ -25,6 +25,7 @@ import StatusContent from 'mastodon/components/status_content';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' },
@@ -136,9 +137,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div>
<div className='conversation__content__names animate-parent'>
<AnimateEmojiProvider className='conversation__content__names'>
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div>
</AnimateEmojiProvider>
</div>
<StatusContent

View File

@@ -23,8 +23,6 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
export const EMOJI_TYPE_UNICODE = 'unicode';
export const EMOJI_TYPE_CUSTOM = 'custom';
export const EMOJI_STATE_MISSING = 'missing';
export const EMOJIS_WITH_DARK_BORDER = [
'🎱', // 1F3B1
'🐜', // 1F41C

View File

@@ -197,11 +197,18 @@ function toLoadedLocale(localeString: string) {
log(`Locale ${locale} is different from provided ${localeString}`);
}
if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`);
throw new LocaleNotLoadedError(locale);
}
return locale;
}
export class LocaleNotLoadedError extends Error {
constructor(locale: Locale) {
super(`Locale ${locale} is not loaded in emoji database`);
this.name = 'LocaleNotLoadedError';
}
}
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) {
return true;

View File

@@ -1,70 +0,0 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import classNames from 'classnames';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
ComponentPropsWithoutRef<Element>,
'dangerouslySetInnerHTML' | 'className'
> & {
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
className?: string;
};
export const ModernEmojiHTML = ({
extraEmojis,
htmlString,
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
className = '',
...props
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;
}
return (
<Wrapper
{...props}
className={classNames(className, 'animate-parent')}
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
/>
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const {
as: asElement,
htmlString,
extraEmojis,
className,
shallow: _,
...rest
} = props;
const Wrapper = asElement ?? 'div';
return (
<Wrapper
{...rest}
dangerouslySetInnerHTML={{ __html: htmlString }}
className={classNames(className, 'animate-parent')}
/>
);
};

View File

@@ -1,4 +1,4 @@
import initialState from '@/mastodon/initial_state';
import { initialState } from '@/mastodon/initial_state';
import { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale';

View File

@@ -1,8 +1,6 @@
import { flattenEmojiData } from 'emojibase';
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import {
putEmojiData,
putCustomEmojiData,
@@ -10,7 +8,7 @@ import {
putLatestEtag,
} from './database';
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
import type { LocaleOrCustom } from './types';
import type { CustomEmojiData, LocaleOrCustom } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('loader');
@@ -27,7 +25,7 @@ export async function importEmojiData(localeString: string) {
}
export async function importCustomEmojiData() {
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
if (!emojis) {
return;
}

View File

@@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json';
import {
twemojiHasBorder,
twemojiToUnicodeInfo,
unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex,
} from './normalize';
@@ -57,26 +54,6 @@ describe('unicodeToTwemojiHex', () => {
});
});
describe('twemojiHasBorder', () => {
test.concurrent.for(
svgFileNames
.filter((file) => file.endsWith('_border'))
.map((file) => {
const hexCode = file.replace('_border', '');
return [
hexCode,
CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()),
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
] as const;
}),
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
const result = twemojiHasBorder(hexCode);
expect(result).toHaveProperty('hexCode', hexCode);
expect(result).toHaveProperty('hasLightBorder', isLight);
expect(result).toHaveProperty('hasDarkBorder', isDark);
});
});
describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));

View File

@@ -1,5 +1,7 @@
import { isList } from 'immutable';
import { assetHost } from '@/mastodon/utils/config';
import {
VARIATION_SELECTOR_CODE,
KEYCAP_CODE,
@@ -9,11 +11,7 @@ import {
EMOJIS_WITH_DARK_BORDER,
EMOJIS_WITH_LIGHT_BORDER,
} from './constants';
import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
TwemojiBorderInfo,
} from './types';
import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types';
// Misc codes that have special handling
const SKIER_CODE = 0x26f7;
@@ -67,21 +65,17 @@ export const CODES_WITH_DARK_BORDER =
export const CODES_WITH_LIGHT_BORDER =
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
const normalizedHex = twemojiHex.toUpperCase();
let hasLightBorder = false;
let hasDarkBorder = false;
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
hasLightBorder = true;
export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
let url = `${assetHost}/emoji/${normalizedHex}`;
if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
url += '_border';
}
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
hasDarkBorder = true;
url += '_border';
}
return {
hexCode: twemojiHex,
hasLightBorder,
hasDarkBorder,
};
url += '.svg';
return url;
}
interface TwemojiSpecificEmoji {

View File

@@ -1,10 +1,6 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import { EMOJI_MODE_TWEMOJI } from './constants';
import * as db from './database';
import {
emojifyElement,
@@ -12,7 +8,7 @@ import {
testCacheClear,
tokenizeText,
} from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
import type { EmojiAppState } from './types';
function mockDatabase() {
return {
@@ -40,18 +36,6 @@ const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
const expectedCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
const expectedRemoteCustomEmojiImage =
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
const mockExtraCustom: ExtraCustomEmojiMap = {
remote: {
shortcode: 'remote',
static_url: 'remote.social/static',
url: 'remote.social/custom',
},
};
function testAppState(state: Partial<EmojiAppState> = {}) {
return {
@@ -86,64 +70,10 @@ describe('emojifyElement', () => {
'en',
);
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
'custom',
':custom:',
]);
});
test('emojifies custom emoji in native mode', async () => {
const { searchEmojisByHexcodes } = mockDatabase();
const actual = await emojifyElement(
testElement(),
testAppState({ mode: EMOJI_MODE_NATIVE }),
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
});
test('emojifies flag emoji in native-with-flags mode', async () => {
const { searchEmojisByHexcodes } = mockDatabase();
const actual = await emojifyElement(
testElement(),
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
});
test('emojifies everything in twemoji mode', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(testElement(), testAppState());
assert(actual);
expect(actual.innerHTML).toBe(
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
});
test('emojifies with provided custom emoji', async () => {
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
mockDatabase();
const actual = await emojifyElement(
testElement('<p>hi :remote:</p>'),
testAppState(),
mockExtraCustom,
);
assert(actual);
expect(actual.innerHTML).toBe(
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
);
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
});
test('returns null when no emoji are found', async () => {
mockDatabase();
const actual = await emojifyElement(
@@ -165,28 +95,9 @@ describe('emojifyText', () => {
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
});
test('renders custom emojis', async () => {
mockDatabase();
const actual = await emojifyText('Hello :custom:!', testAppState());
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
});
test('renders provided extra emojis', async () => {
const actual = await emojifyText(
'remote emoji :remote:',
testAppState(),
mockExtraCustom,
);
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
});
});
describe('tokenizeText', () => {
test('returns empty array for string with only whitespace', () => {
expect(tokenizeText(' \n')).toEqual([]);
});
test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']);
});
@@ -212,7 +123,7 @@ describe('tokenizeText', () => {
'Hello ',
{
type: 'custom',
code: 'smile',
code: ':smile:',
},
'!!',
]);
@@ -223,7 +134,7 @@ describe('tokenizeText', () => {
'Hello ',
{
type: 'custom',
code: 'smile_123',
code: ':smile_123:',
},
'!!',
]);
@@ -239,7 +150,7 @@ describe('tokenizeText', () => {
' ',
{
type: 'custom',
code: 'smile',
code: ':smile:',
},
'!!',
]);

View File

@@ -1,6 +1,5 @@
import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance';
import {
@@ -8,38 +7,130 @@ import {
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants';
import {
loadCustomEmojiByShortcode,
loadEmojiByHexcode,
LocaleNotLoadedError,
searchCustomEmojisByShortcodes,
searchEmojisByHexcodes,
} from './database';
import {
emojiToUnicodeHex,
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import { importEmojiData } from './loader';
import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
import type {
CustomEmojiToken,
EmojiAppState,
EmojiLoadedState,
EmojiMode,
EmojiState,
EmojiStateCustom,
EmojiStateMap,
EmojiToken,
EmojiStateUnicode,
ExtraCustomEmojiMap,
LocaleOrCustom,
UnicodeEmojiToken,
} from './types';
import {
anyEmojiRegex,
emojiLogger,
isCustomEmoji,
isUnicodeEmoji,
stringHasAnyEmoji,
stringHasUnicodeFlags,
} from './utils';
const log = emojiLogger('render');
/**
* Parses emoji string to extract emoji state.
* @param code Hex code or custom shortcode.
* @param customEmoji Extra custom emojis.
*/
export function stringToEmojiState(
code: string,
customEmoji: ExtraCustomEmojiMap = {},
): EmojiState | null {
if (isUnicodeEmoji(code)) {
return {
type: EMOJI_TYPE_UNICODE,
code: emojiToUnicodeHex(code),
};
}
if (isCustomEmoji(code)) {
const shortCode = code.slice(1, -1);
return {
type: EMOJI_TYPE_CUSTOM,
code: shortCode,
data: customEmoji[shortCode],
};
}
return null;
}
/**
* Loads emoji data into the given state if not already loaded.
* @param state Emoji state to load data for.
* @param locale Locale to load data for. Only for Unicode emoji.
* @param retry Internal. Whether this is a retry after loading the locale.
*/
export async function loadEmojiDataToState(
state: EmojiState,
locale: string,
retry = false,
): Promise<EmojiLoadedState | null> {
if (isStateLoaded(state)) {
return state;
}
// First, try to load the data from IndexedDB.
try {
// This is duplicative, but that's because TS can't distinguish the state type easily.
if (state.type === EMOJI_TYPE_UNICODE) {
const data = await loadEmojiByHexcode(state.code, locale);
if (data) {
return {
...state,
data,
};
}
} else {
const data = await loadCustomEmojiByShortcode(state.code);
if (data) {
return {
...state,
data,
};
}
}
// If not found, assume it's not an emoji and return null.
log(
'Could not find emoji %s of type %s for locale %s',
state.code,
state.type,
locale,
);
return null;
} catch (err: unknown) {
// If the locale is not loaded, load it and retry once.
if (!retry && err instanceof LocaleNotLoadedError) {
log(
'Error loading emoji %s for locale %s, loading locale and retrying.',
state.code,
locale,
);
await importEmojiData(locale); // Use this from the loader file as it can be awaited.
return loadEmojiDataToState(state, locale, true);
}
console.warn('Error loading emoji data, not retrying:', state, locale, err);
return null;
}
}
export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
return !!state.data;
}
/**
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
*/
@@ -177,7 +268,11 @@ async function textToElementArray(
if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
state = {
type: EMOJI_TYPE_CUSTOM,
data: extraEmojiData,
code: token.code,
};
} else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
}
@@ -189,7 +284,7 @@ async function textToElementArray(
}
// If the state is valid, create an image element. Otherwise, just append as text.
if (state && typeof state !== 'string') {
if (state && typeof state !== 'string' && isStateLoaded(state)) {
const image = stateToImage(state, appState);
renderedFragments.push(image);
continue;
@@ -202,11 +297,11 @@ async function textToElementArray(
return renderedFragments;
}
type TokenizedText = (string | EmojiToken)[];
type TokenizedText = (string | EmojiState)[];
export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) {
return [];
return [text];
}
const tokens = [];
@@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText {
// Custom emoji
tokens.push({
type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons
} satisfies CustomEmojiToken);
code,
} satisfies EmojiStateCustom);
} else {
// Unicode emoji
tokens.push({
type: EMOJI_TYPE_UNICODE,
code: code,
} satisfies UnicodeEmojiToken);
} satisfies EmojiStateUnicode);
}
lastIndex = match.index + code.length;
}
@@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.hexcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
cache.set(emoji.hexcode, {
type: EMOJI_TYPE_UNICODE,
data: emoji,
code: emoji.hexcode,
});
}
localeCacheMap.set(currentLocale, cache);
}
@@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
}
const notFoundEmojis = missingEmojis.filter((code) =>
emojis.every((emoji) => emoji.shortcode !== code),
);
for (const code of notFoundEmojis) {
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
cache.set(emoji.shortcode, {
type: EMOJI_TYPE_CUSTOM,
data: emoji,
code: emoji.shortcode,
});
}
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
}
}
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
if (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly.
@@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) {
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
let fileName = emojiInfo.hexCode;
if (
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
(!appState.darkTheme && emojiInfo.hasLightBorder)
) {
fileName = `${emojiInfo.hexCode}_border`;
}
image.alt = state.data.unicode;
image.title = state.data.label;
image.src = `${assetHost}/emoji/${fileName}.svg`;
image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
} else {
// Custom emoji
const shortCode = `:${state.data.shortcode}:`;

View File

@@ -10,7 +10,6 @@ import type {
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
EMOJI_STATE_MISSING,
EMOJI_TYPE_CUSTOM,
EMOJI_TYPE_UNICODE,
} from './constants';
@@ -29,45 +28,40 @@ export interface EmojiAppState {
darkTheme: boolean;
}
export interface UnicodeEmojiToken {
type: typeof EMOJI_TYPE_UNICODE;
code: string;
}
export interface CustomEmojiToken {
type: typeof EMOJI_TYPE_CUSTOM;
code: string;
}
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
export type CustomEmojiData = ApiCustomEmojiJSON;
export type UnicodeEmojiData = FlatCompactEmoji;
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
type CustomEmojiRenderFields = Pick<
CustomEmojiData,
'shortcode' | 'static_url' | 'url'
>;
export interface EmojiStateUnicode {
type: typeof EMOJI_TYPE_UNICODE;
data: UnicodeEmojiData;
code: string;
data?: UnicodeEmojiData;
}
export interface EmojiStateCustom {
type: typeof EMOJI_TYPE_CUSTOM;
data: CustomEmojiRenderFields;
code: string;
data?: CustomEmojiRenderFields;
}
export type EmojiState =
| EmojiStateMissing
| EmojiStateUnicode
| EmojiStateCustom;
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
export type EmojiLoadedState =
| Required<EmojiStateUnicode>
| Required<EmojiStateCustom>;
export type EmojiStateMap = LimitedCache<string, EmojiState>;
export type CustomEmojiMapArg =
| ExtraCustomEmojiMap
| ImmutableList<CustomEmoji>;
export type CustomEmojiRenderFields = Pick<
CustomEmojiData,
'shortcode' | 'static_url' | 'url'
export type ExtraCustomEmojiMap = Record<
string,
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
>;
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
export interface TwemojiBorderInfo {
hexCode: string;

View File

@@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
}
export function isUnicodeEmoji(input: string): boolean {
return (
input.length > 0 &&
new RegExp(`^(${EMOJI_REGEX})+$`, supportedFlags()).test(input)
);
}
export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) {
return new RegExp(
@@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean {
// Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
export function isCustomEmoji(input: string): boolean {
return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input);
}
export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input);
}

View File

@@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
import type { List as ImmutableList, RecordOf } from 'immutable';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
@@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
).size;
return (
<div
className='notification-group__embedded-status animate-parent'
<AnimateEmojiProvider
className='notification-group__embedded-status'
role='button'
tabIndex={-1}
onMouseDown={handleMouseDown}
@@ -148,6 +149,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
)}
</div>
)}
</div>
</AnimateEmojiProvider>
);
};

View File

@@ -2,25 +2,19 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import { replyCompose } from 'mastodon/actions/compose';
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
import { toggleFavourite } from 'mastodon/actions/interactions';
import { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button';
import { BoostButton } from 'mastodon/components/status/boost_button';
import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import type { Status } from 'mastodon/models/status';
import { makeGetStatus } from 'mastodon/selectors';
@@ -120,29 +114,6 @@ export const Footer: React.FC<{
}
}, [dispatch, status, signedIn]);
const handleReblogClick = useCallback(
(e: React.MouseEvent) => {
if (!status) {
return;
}
if (signedIn) {
dispatch(toggleReblog(status.get('id'), e.shiftKey));
} else {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
accountId: status.getIn(['account', 'id']),
url: status.get('uri'),
},
}),
);
}
},
[dispatch, status, signedIn],
);
const handleOpenClick = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || !status) {
@@ -160,13 +131,6 @@ export const Footer: React.FC<{
return null;
}
const publicStatus = ['public', 'unlisted'].includes(
status.get('visibility') as string,
);
const reblogPrivate =
status.getIn(['account', 'id']) === me &&
status.get('visibility') === 'private';
let replyIcon, replyIconComponent, replyTitle;
if (status.get('in_reply_to_id', null) === null) {
@@ -179,24 +143,6 @@ export const Footer: React.FC<{
replyTitle = intl.formatMessage(messages.replyAll);
}
let reblogTitle, reblogIconComponent;
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
reblogIconComponent = publicStatus
? RepeatActiveIcon
: RepeatPrivateActiveIcon;
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
reblogIconComponent = RepeatIcon;
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
reblogIconComponent = RepeatPrivateIcon;
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
reblogIconComponent = RepeatDisabledIcon;
}
const favouriteTitle = intl.formatMessage(
status.get('favourited') ? messages.removeFavourite : messages.favourite,
);
@@ -222,19 +168,7 @@ export const Footer: React.FC<{
counter={status.get('replies_count') as number}
/>
<IconButton
className={classNames('status__action-bar-button', { reblogPrivate })}
disabled={!publicStatus && !reblogPrivate}
active={status.get('reblogged') as boolean}
title={reblogTitle}
icon='retweet'
iconComponent={reblogIconComponent}
onClick={handleReblogClick}
counter={
(status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
}
/>
<BoostButton counters status={status} />
<IconButton
className='status__action-bar-button star-icon'

View File

@@ -12,6 +12,8 @@ import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list';
import { useIdentity } from 'mastodon/identity_context';
import { domain } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import Column from '../ui/components/column';
@@ -31,9 +33,18 @@ export const Quotes: React.FC<{
const statusId = params?.statusId;
const { accountId: me } = useIdentity();
const isCorrectStatusId: boolean = useAppSelector(
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId,
);
const quotedAccountId = useAppSelector(
(state) =>
state.statuses.getIn([statusId, 'account']) as string | undefined,
);
const quotedAccount = useAppSelector((state) =>
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
);
const statusIds = useAppSelector((state) =>
state.status_lists.getIn(['quotes', 'items'], emptyList),
);
@@ -74,6 +85,32 @@ export const Quotes: React.FC<{
/>
);
let prependMessage;
if (me === quotedAccountId) {
prependMessage = null;
} else if (quotedAccount?.username === quotedAccount?.acct) {
// Local account, we know this to be exhaustive
prependMessage = (
<div className='follow_requests-unlocked_explanation'>
<FormattedMessage
id='status.quotes.local_other_disclaimer'
defaultMessage='Quotes rejected by the author will not be shown.'
/>
</div>
);
} else {
prependMessage = (
<div className='follow_requests-unlocked_explanation'>
<FormattedMessage
id='status.quotes.remote_other_disclaimer'
defaultMessage='Only quotes from {domain} are guaranteed to be shown here. Quotes rejected by the author will not be shown.'
values={{ domain: <strong>{domain}</strong> }}
/>
</div>
);
}
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
@@ -100,6 +137,7 @@ export const Quotes: React.FC<{
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
prepend={prependMessage}
/>
<Helmet>

View File

@@ -11,7 +11,7 @@ import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
import initialState from 'mastodon/initial_state';
import { initialState } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';

View File

@@ -31,7 +31,7 @@ import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Audio } from 'mastodon/features/audio';
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
import { Video } from 'mastodon/features/video';
import { me } from 'mastodon/initial_state';
import { useIdentity } from 'mastodon/identity_context';
import Card from './card';
@@ -75,6 +75,8 @@ export const DetailedStatus: React.FC<{
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
const nodeRef = useRef<HTMLDivElement>();
const { signedIn } = useIdentity();
const handleOpenVideo = useCallback(
(options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) ||
@@ -283,7 +285,7 @@ export const DetailedStatus: React.FC<{
if (['private', 'direct'].includes(status.get('visibility') as string)) {
quotesLink = '';
} else if (status.getIn(['account', 'id']) === me) {
} else if (signedIn) {
quotesLink = (
<Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}

View File

@@ -11,7 +11,7 @@ export interface BaseConfirmationModalProps {
export const ConfirmationModal: React.FC<
{
title: React.ReactNode;
message: React.ReactNode;
message?: React.ReactNode;
confirm: React.ReactNode;
cancel?: React.ReactNode;
secondary?: React.ReactNode;
@@ -48,7 +48,7 @@ export const ConfirmationModal: React.FC<
<div className='safety-action-modal__top'>
<div className='safety-action-modal__confirmation'>
<h1>{title}</h1>
<p>{message}</p>
{message && <p>{message}</p>}
</div>
</div>

View File

@@ -5,7 +5,9 @@ export {
ConfirmReplyModal,
ConfirmEditStatusModal,
} from './discard_draft_confirmation';
export { ConfirmWithdrawRequestModal } from './withdraw_follow_request';
export { ConfirmUnfollowModal } from './unfollow';
export { ConfirmUnblockModal } from './unblock';
export { ConfirmClearNotificationsModal } from './clear_notifications';
export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list';

View File

@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { unblockAccount } from 'mastodon/actions/accounts';
import type { Account } from 'mastodon/models/account';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
unblockConfirm: {
id: 'confirmations.unblock.confirm',
defaultMessage: 'Unblock',
},
});
export const ConfirmUnblockModal: React.FC<
{
account: Account;
} & BaseConfirmationModalProps
> = ({ account, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(unblockAccount(account.id));
}, [dispatch, account.id]);
return (
<ConfirmationModal
title={
<FormattedMessage
id='confirmations.unblock.title'
defaultMessage='Unblock {name}?'
values={{ name: `@${account.acct}` }}
/>
}
confirm={intl.formatMessage(messages.unblockConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@@ -10,10 +10,6 @@ import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
unfollowTitle: {
id: 'confirmations.unfollow.title',
defaultMessage: 'Unfollow user?',
},
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
@@ -34,12 +30,11 @@ export const ConfirmUnfollowModal: React.FC<
return (
<ConfirmationModal
title={intl.formatMessage(messages.unfollowTitle)}
message={
title={
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.acct}</strong> }}
id='confirmations.unfollow.title'
defaultMessage='Unfollow {name}?'
values={{ name: `@${account.acct}` }}
/>
}
confirm={intl.formatMessage(messages.unfollowConfirm)}

View File

@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { unfollowAccount } from 'mastodon/actions/accounts';
import type { Account } from 'mastodon/models/account';
import { useAppDispatch } from 'mastodon/store';
import type { BaseConfirmationModalProps } from './confirmation_modal';
import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({
withdrawConfirm: {
id: 'confirmations.withdraw_request.confirm',
defaultMessage: 'Withdraw request',
},
});
export const ConfirmWithdrawRequestModal: React.FC<
{
account: Account;
} & BaseConfirmationModalProps
> = ({ account, onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const onConfirm = useCallback(() => {
dispatch(unfollowAccount(account.id));
}, [dispatch, account.id]);
return (
<ConfirmationModal
title={
<FormattedMessage
id='confirmations.withdraw_request.title'
defaultMessage='Withdraw request to follow {name}?'
values={{ name: `@${account.acct}` }}
/>
}
confirm={intl.formatMessage(messages.withdrawConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@@ -32,7 +32,9 @@ import {
ConfirmDeleteListModal,
ConfirmReplyModal,
ConfirmEditStatusModal,
ConfirmUnblockModal,
ConfirmUnfollowModal,
ConfirmWithdrawRequestModal,
ConfirmClearNotificationsModal,
ConfirmLogOutModal,
ConfirmFollowToListModal,
@@ -57,7 +59,9 @@ export const MODAL_COMPONENTS = {
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
'CONFIRM_UNFOLLOW': () => Promise.resolve({ default: ConfirmUnfollowModal }),
'CONFIRM_WITHDRAW_REQUEST': () => Promise.resolve({ default: ConfirmWithdrawRequestModal }),
'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }),
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),

View File

@@ -27,7 +27,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act
import { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines';
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
import { initialState, me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar';

View File

@@ -1,4 +1,4 @@
import initialState from '@/mastodon/initial_state';
import { initialState } from '@/mastodon/initial_state';
interface FocusColumnOptions {
index?: number;

View File

@@ -1,145 +0,0 @@
// @ts-check
/**
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
*/
/**
* @typedef InitialStateMeta
* @property {string} access_token
* @property {boolean=} advanced_layout
* @property {boolean} auto_play_gif
* @property {boolean} activity_api_enabled
* @property {string} admin
* @property {boolean=} boost_modal
* @property {boolean=} delete_modal
* @property {boolean=} missing_alt_text_modal
* @property {boolean=} disable_swiping
* @property {boolean=} disable_hover_cards
* @property {string=} disabled_account_id
* @property {string} display_media
* @property {string} domain
* @property {boolean=} expand_spoilers
* @property {boolean} limited_federation_mode
* @property {string} locale
* @property {string | null} mascot
* @property {string=} me
* @property {string=} moved_to_account_id
* @property {string=} owner
* @property {boolean} profile_directory
* @property {boolean} registrations_open
* @property {boolean} reduce_motion
* @property {string} repository
* @property {boolean} search_enabled
* @property {boolean} trends_enabled
* @property {boolean} single_user_mode
* @property {string} source_url
* @property {string} streaming_api_base_url
* @property {boolean} timeline_preview
* @property {string} title
* @property {boolean} show_trends
* @property {boolean} trends_as_landing_page
* @property {boolean} use_blurhash
* @property {boolean=} use_pending_items
* @property {string} version
* @property {string} sso_redirect
* @property {string} status_page_url
* @property {boolean} terms_of_service_enabled
* @property {string?} emoji_style
*/
/**
* @typedef Role
* @property {string} id
* @property {string} name
* @property {string} permissions
* @property {string} color
* @property {boolean} highlighted
*/
/**
* @typedef InitialState
* @property {Record<string, import("./api_types/accounts").ApiAccountJSON>} accounts
* @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta
* @property {Role?} role
* @property {string[]} features
*/
const element = document.getElementById('initial-state');
/** @type {InitialState | undefined} */
const initialState = element?.textContent && JSON.parse(element.textContent);
/** @type {string} */
const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? '';
/** @type {boolean} */
export const hasMultiColumnPath = initialPath === '/'
|| initialPath === '/getting-started'
|| initialPath === '/home'
|| initialPath.startsWith('/deck');
/**
* @template {keyof InitialStateMeta} K
* @param {K} prop
* @returns {InitialStateMeta[K] | undefined}
*/
const getMeta = (prop) => initialState?.meta && initialState.meta[prop];
export const activityApiEnabled = getMeta('activity_api_enabled');
export const autoPlayGif = getMeta('auto_play_gif');
export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_modal');
export const missingAltTextModal = getMeta('missing_alt_text_modal');
export const disableSwiping = getMeta('disable_swiping');
export const disableHoverCards = getMeta('disable_hover_cards');
export const disabledAccountId = getMeta('disabled_account_id');
export const displayMedia = getMeta('display_media');
export const domain = getMeta('domain');
export const emojiStyle = getMeta('emoji_style') || 'auto';
export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const me = getMeta('me');
export const movedToAccountId = getMeta('moved_to_account_id');
export const owner = getMeta('owner');
export const profile_directory = getMeta('profile_directory');
export const reduceMotion = getMeta('reduce_motion');
export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository');
export const searchEnabled = getMeta('search_enabled');
export const trendsEnabled = getMeta('trends_enabled');
export const showTrends = getMeta('show_trends');
export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview');
export const title = getMeta('title');
export const trendsAsLanding = getMeta('trends_as_landing_page');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
const displayNames = Intl.DisplayNames && new Intl.DisplayNames(getMeta('locale'), {
type: 'language',
fallback: 'none',
languageDisplay: 'standard',
});
export const languages = initialState?.languages?.map(lang => {
// zh-YUE is not a valid CLDR unicode_language_id
return [lang[0], displayNames?.of(lang[0].replace('zh-YUE', 'yue')) || lang[1], lang[2]];
});
/**
* @returns {string | undefined}
*/
export function getAccessToken() {
return getMeta('access_token');
}
export default initialState;

View File

@@ -0,0 +1,141 @@
import type { ApiAccountJSON } from './api_types/accounts';
type InitialStateLanguage = [code: string, name: string, localName: string];
interface InitialStateMeta {
access_token: string;
advanced_layout?: boolean;
auto_play_gif: boolean;
activity_api_enabled: boolean;
admin: string;
boost_modal?: boolean;
delete_modal?: boolean;
missing_alt_text_modal?: boolean;
disable_swiping?: boolean;
disable_hover_cards?: boolean;
disabled_account_id?: string;
display_media: string;
domain: string;
expand_spoilers?: boolean;
limited_federation_mode: boolean;
locale: string;
mascot: string | null;
me?: string;
moved_to_account_id?: string;
owner?: string;
profile_directory: boolean;
registrations_open: boolean;
reduce_motion: boolean;
repository: string;
search_enabled: boolean;
trends_enabled: boolean;
single_user_mode: boolean;
source_url: string;
streaming_api_base_url: string;
timeline_preview: boolean;
title: string;
show_trends: boolean;
trends_as_landing_page: boolean;
use_blurhash: boolean;
use_pending_items?: boolean;
version: string;
sso_redirect: string;
status_page_url: string;
terms_of_service_enabled: boolean;
emoji_style?: string;
}
interface Role {
id: string;
name: string;
permissions: string;
color: string;
highlighted: boolean;
}
export interface InitialState {
accounts: Record<string, ApiAccountJSON>;
languages: InitialStateLanguage[];
critical_updates_pending?: boolean;
meta: InitialStateMeta;
role?: Role;
features: string[];
}
const element = document.getElementById('initial-state');
export const initialState: InitialState | undefined = element?.textContent
? (JSON.parse(element.textContent) as InitialState)
: undefined;
const initialPath: string =
document
.querySelector('head meta[name=initialPath]')
?.getAttribute('content') ?? '';
export const hasMultiColumnPath: boolean =
initialPath === '/' ||
initialPath === '/getting-started' ||
initialPath === '/home' ||
initialPath.startsWith('/deck');
function getMeta<K extends keyof InitialStateMeta>(
prop: K,
): InitialStateMeta[K] | undefined {
return initialState?.meta[prop];
}
export const activityApiEnabled = getMeta('activity_api_enabled');
export const autoPlayGif = getMeta('auto_play_gif');
export const boostModal = getMeta('boost_modal');
export const deleteModal = getMeta('delete_modal');
export const missingAltTextModal = getMeta('missing_alt_text_modal');
export const disableSwiping = getMeta('disable_swiping');
export const disableHoverCards = getMeta('disable_hover_cards');
export const disabledAccountId = getMeta('disabled_account_id');
export const displayMedia = getMeta('display_media');
export const domain = getMeta('domain');
export const emojiStyle = getMeta('emoji_style') ?? 'auto';
export const expandSpoilers = getMeta('expand_spoilers');
export const forceSingleColumn = !getMeta('advanced_layout');
export const limitedFederationMode = getMeta('limited_federation_mode');
export const mascot = getMeta('mascot');
export const me = getMeta('me');
export const movedToAccountId = getMeta('moved_to_account_id');
export const owner = getMeta('owner');
export const profile_directory = getMeta('profile_directory');
export const reduceMotion = getMeta('reduce_motion');
export const registrationsOpen = getMeta('registrations_open');
export const repository = getMeta('repository');
export const searchEnabled = getMeta('search_enabled');
export const trendsEnabled = getMeta('trends_enabled');
export const showTrends = getMeta('show_trends');
export const singleUserMode = getMeta('single_user_mode');
export const source_url = getMeta('source_url');
export const timelinePreview = getMeta('timeline_preview');
export const title = getMeta('title');
export const trendsAsLanding = getMeta('trends_as_landing_page');
export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const criticalUpdatesPending = initialState?.critical_updates_pending;
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
const displayNames = new Intl.DisplayNames(getMeta('locale'), {
type: 'language',
fallback: 'none',
languageDisplay: 'standard',
});
export const languages = initialState?.languages.map((lang) => {
// zh-YUE is not a valid CLDR unicode_language_id
return [
lang[0],
displayNames.of(lang[0].replace('zh-YUE', 'yue')) ?? lang[1],
lang[2],
];
});
export function getAccessToken(): string | undefined {
return getMeta('access_token');
}

View File

@@ -133,7 +133,6 @@
"confirmations.mute.confirm": "Silenciar",
"confirmations.redraft.confirm": "Borrar y tornar ta borrador",
"confirmations.unfollow.confirm": "Deixar de seguir",
"confirmations.unfollow.message": "Yes seguro que quiers deixar de seguir a {name}?",
"conversation.delete": "Borrar conversación",
"conversation.mark_as_read": "Marcar como leyiu",
"conversation.open": "Veyer conversación",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "لا يمكن التراجع عن هذا الإجراء.",
"confirmations.revoke_quote.title": "أتريد إزالة المنشور؟",
"confirmations.unfollow.confirm": "إلغاء المتابعة",
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
"confirmations.unfollow.title": "إلغاء متابعة المستخدم؟",
"content_warning.hide": "إخفاء المنشور",
"content_warning.show": "إظهار على أي حال",
"content_warning.show_more": "إظهار المزيد",

View File

@@ -155,8 +155,6 @@
"confirmations.redraft.confirm": "Desaniciar y reeditar",
"confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?",
"confirmations.unfollow.confirm": "Dexar de siguir",
"confirmations.unfollow.message": "¿De xuru que quies dexar de siguir a {name}?",
"confirmations.unfollow.title": "¿Dexar de siguir al usuariu?",
"content_warning.hide": "Esconder la publicación",
"content_warning.show": "Amosar de toes toes",
"content_warning.show_more": "Amosar más",

View File

@@ -248,8 +248,6 @@
"confirmations.revoke_quote.message": "Bu əməliyyatın geri dönüşü yoxdur.",
"confirmations.revoke_quote.title": "Göndəriş silinsin?",
"confirmations.unfollow.confirm": "İzləmədən çıxar",
"confirmations.unfollow.message": "{name} izləmədən çıxmaq istədiyinizə əminsiniz?",
"confirmations.unfollow.title": "İstifadəçi izləmədən çıxarılsın?",
"content_warning.hide": "Paylaşımı gizlət",
"content_warning.show": "Yenə də göstər",
"content_warning.show_more": "Daha çox göstər",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Гэтае дзеянне немагчыма адмяніць.",
"confirmations.revoke_quote.title": "Выдаліць допіс?",
"confirmations.unfollow.confirm": "Адпісацца",
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
"content_warning.hide": "Схаваць допіс",
"content_warning.show": "Усё адно паказаць",
"content_warning.show_more": "Паказаць усё роўна",

View File

@@ -248,8 +248,6 @@
"confirmations.revoke_quote.message": "Действието е неотменимо.",
"confirmations.revoke_quote.title": "Премахвате ли публикацията?",
"confirmations.unfollow.confirm": "Без следване",
"confirmations.unfollow.message": "Наистина ли искате вече да не следвате {name}?",
"confirmations.unfollow.title": "Спирате ли да следвате потребителя?",
"content_warning.hide": "Скриване на публ.",
"content_warning.show": "Нека се покаже",
"content_warning.show_more": "Показване на още",

View File

@@ -152,7 +152,6 @@
"confirmations.mute.confirm": "সরিয়ে ফেলুন",
"confirmations.redraft.confirm": "মুছে ফেলুন এবং আবার সম্পাদন করুন",
"confirmations.unfollow.confirm": "অনুসরণ বন্ধ করো",
"confirmations.unfollow.message": "তুমি কি নিশ্চিত {name} কে আর অনুসরণ করতে চাও না?",
"conversation.delete": "কথোপকথন মুছে ফেলুন",
"conversation.mark_as_read": "পঠিত হিসেবে চিহ্নিত করুন",
"conversation.open": "কথপোকথন দেখান",

View File

@@ -217,8 +217,6 @@
"confirmations.revoke_quote.confirm": "Dilemel an embannadur",
"confirmations.revoke_quote.title": "Dilemel an embannadur?",
"confirmations.unfollow.confirm": "Diheuliañ",
"confirmations.unfollow.message": "Ha sur oc'h e fell deoc'h paouez da heuliañ {name} ?",
"confirmations.unfollow.title": "Paouez da heuliañ an implijer·ez?",
"content_warning.hide": "Kuzhat an embannadur",
"content_warning.show": "Diskwel memes tra",
"content_warning.show_more": "Diskouez muioc'h",

View File

@@ -248,8 +248,6 @@
"confirmations.revoke_quote.message": "Aquesta acció no es pot desfer.",
"confirmations.revoke_quote.title": "Eliminar la publicació?",
"confirmations.unfollow.confirm": "Deixa de seguir",
"confirmations.unfollow.message": "Segur que vols deixar de seguir {name}?",
"confirmations.unfollow.title": "Deixar de seguir l'usuari?",
"content_warning.hide": "Amaga la publicació",
"content_warning.show": "Mostra-la igualment",
"content_warning.show_more": "Mostra'n més",

View File

@@ -160,7 +160,6 @@
"confirmations.redraft.confirm": "سڕینەوە & دووبارە ڕەشکردنەوە",
"confirmations.redraft.message": "دڵنیای دەتەوێت ئەم پۆستە بسڕیتەوە و دووبارە دایبڕێژیتەوە؟ فەڤۆریت و بووستەکان لەدەست دەچن، وەڵامەکانی پۆستە ئەسڵیەکەش هەتیو دەبن.",
"confirmations.unfollow.confirm": "بەدوادانەچو",
"confirmations.unfollow.message": "ئایا دڵنیایت لەوەی دەتەوێت پەیڕەوی {name}?",
"conversation.delete": "سڕینەوەی گفتوگۆ",
"conversation.mark_as_read": "نیشانەکردن وەک خوێندراوە",
"conversation.open": "نیشاندان گفتوگۆ",

View File

@@ -87,7 +87,6 @@
"confirmations.mute.confirm": "Piattà",
"confirmations.redraft.confirm": "Sguassà è riscrive",
"confirmations.unfollow.confirm": "Disabbunassi",
"confirmations.unfollow.message": "Site sicuru·a ch'ùn vulete più siguità @{name}?",
"conversation.delete": "Sguassà a cunversazione",
"conversation.mark_as_read": "Marcà cum'è lettu",
"conversation.open": "Vede a cunversazione",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Tuto akci nelze vrátit zpět.",
"confirmations.revoke_quote.title": "Odstranit příspěvek?",
"confirmations.unfollow.confirm": "Přestat sledovat",
"confirmations.unfollow.message": "Opravdu chcete {name} přestat sledovat?",
"confirmations.unfollow.title": "Přestat sledovat uživatele?",
"content_warning.hide": "Skrýt příspěvek",
"content_warning.show": "Přesto zobrazit",
"content_warning.show_more": "Zobrazit více",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Does dim modd dadwneud y weithred hon.",
"confirmations.revoke_quote.title": "Dileu'r postiad?",
"confirmations.unfollow.confirm": "Dad-ddilyn",
"confirmations.unfollow.message": "Ydych chi'n siŵr eich bod am ddad-ddilyn {name}?",
"confirmations.unfollow.title": "Dad-ddilyn defnyddiwr?",
"content_warning.hide": "Cuddio'r postiad",
"content_warning.show": "Dangos beth bynnag",
"content_warning.show_more": "Dangos rhagor",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Fjern indlæg",
"confirmations.revoke_quote.message": "Denne handling kan ikke fortrydes.",
"confirmations.revoke_quote.title": "Fjern indlæg?",
"confirmations.unblock.confirm": "Fjern blokering",
"confirmations.unblock.title": "Fjern blokering af {name}?",
"confirmations.unfollow.confirm": "Følg ikke længere",
"confirmations.unfollow.message": "Er du sikker på, at du ikke længere vil følge {name}?",
"confirmations.unfollow.title": "Følg ikke længere bruger?",
"confirmations.unfollow.title": "Følg ikke længere {name}?",
"confirmations.withdraw_request.confirm": "Annullér anmodning",
"confirmations.withdraw_request.title": "Annullér anmodning om at følge {name}?",
"content_warning.hide": "Skjul indlæg",
"content_warning.show": "Vis alligevel",
"content_warning.show_more": "Vis flere",
@@ -920,6 +923,8 @@
"status.quote_private": "Private indlæg kan ikke citeres",
"status.quotes": "{count, plural, one {citat} other {citater}}",
"status.quotes.empty": "Ingen har citeret dette indlæg endnu. Når det sker, vil det fremgå her.",
"status.quotes.local_other_disclaimer": "Citater afvist af forfatteren vil ikke blive vist.",
"status.quotes.remote_other_disclaimer": "Kun citater fra {domain} vises med garanti her. Citater afvist af forfatteren vil ikke blive vist.",
"status.read_more": "Læs mere",
"status.reblog": "Fremhæv",
"status.reblog_or_quote": "Fremhæv eller citér",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Beitrag entfernen",
"confirmations.revoke_quote.message": "Diese Aktion kann nicht rückgängig gemacht werden.",
"confirmations.revoke_quote.title": "Beitrag entfernen?",
"confirmations.unblock.confirm": "Nicht mehr blockieren",
"confirmations.unblock.title": "{name} nicht mehr blockieren?",
"confirmations.unfollow.confirm": "Entfolgen",
"confirmations.unfollow.message": "Möchtest du {name} wirklich entfolgen?",
"confirmations.unfollow.title": "Profil entfolgen?",
"confirmations.unfollow.title": "{name} entfolgen?",
"confirmations.withdraw_request.confirm": "Anfrage zurückziehen",
"confirmations.withdraw_request.title": "Anfrage zum Folgen von {name} zurückziehen?",
"content_warning.hide": "Beitrag ausblenden",
"content_warning.show": "Trotzdem anzeigen",
"content_warning.show_more": "Beitrag anzeigen",
@@ -920,6 +923,8 @@
"status.quote_private": "Private Beiträge können nicht zitiert werden",
"status.quotes": "{count, plural, one {Mal zitiert} other {Mal zitiert}}",
"status.quotes.empty": "Diesen Beitrag hat bisher noch niemand zitiert. Sobald es jemand tut, wird das Profil hier erscheinen.",
"status.quotes.local_other_disclaimer": "Durch Autor*in abgelehnte Zitate werden nicht angezeigt.",
"status.quotes.remote_other_disclaimer": "Nur Zitate von {domain} werden hier garantiert angezeigt. Durch Autor*in abgelehnte Zitate werden nicht angezeigt.",
"status.read_more": "Gesamten Beitrag anschauen",
"status.reblog": "Teilen",
"status.reblog_or_quote": "Teilen oder zitieren",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Αφαίρεση ανάρτησης",
"confirmations.revoke_quote.message": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"confirmations.revoke_quote.title": "Αφαίρεση ανάρτησης;",
"confirmations.unblock.confirm": "Άρση αποκλεισμού",
"confirmations.unblock.title": "Άρση αποκλεισμού {name};",
"confirmations.unfollow.confirm": "Άρση ακολούθησης",
"confirmations.unfollow.message": "Σίγουρα θες να πάψεις να ακολουθείς τον/την {name};",
"confirmations.unfollow.title": "Άρση ακολούθησης;",
"confirmations.unfollow.title": "Κατάργηση ακολούθησης του/της {name};",
"confirmations.withdraw_request.confirm": "Απόσυρση αιτήματος",
"confirmations.withdraw_request.title": "Απόσυρση αιτήματος για να ακολουθήσετε τον/την {name};",
"content_warning.hide": "Απόκρυψη ανάρτησης",
"content_warning.show": "Εμφάνιση ούτως ή άλλως",
"content_warning.show_more": "Εμφάνιση περισσότερων",
@@ -920,6 +923,8 @@
"status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν",
"status.quotes": "{count, plural, one {# παράθεση} other {# παραθέσεις}}",
"status.quotes.empty": "Κανείς δεν έχει παραθέσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει, θα εμφανιστεί εδώ.",
"status.quotes.local_other_disclaimer": "Οι παραθέσεις που απορρίφθηκαν από τον συντάκτη δε θα εμφανιστούν.",
"status.quotes.remote_other_disclaimer": "Μόνο παραθέσεις από το {domain} είναι εγγυημένες ότι θα εμφανιστούν εδώ. Παραθέσεις που απορρίφθηκαν από τον συντάκτη δε θα εμφανιστούν.",
"status.read_more": "Διάβασε περισότερα",
"status.reblog": "Ενίσχυση",
"status.reblog_or_quote": "Ενίσχυση ή παράθεση",

View File

@@ -245,8 +245,6 @@
"confirmations.remove_from_followers.message": "{name} will stop following you. Are you sure you want to proceed?",
"confirmations.remove_from_followers.title": "Remove follower?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"confirmations.unfollow.title": "Unfollow user?",
"content_warning.hide": "Hide post",
"content_warning.show": "Show anyway",
"content_warning.show_more": "Show more",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Remove post",
"confirmations.revoke_quote.message": "This action cannot be undone.",
"confirmations.revoke_quote.title": "Remove post?",
"confirmations.unblock.confirm": "Unblock",
"confirmations.unblock.title": "Unblock {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
"confirmations.unfollow.title": "Unfollow user?",
"confirmations.unfollow.title": "Unfollow {name}?",
"confirmations.withdraw_request.confirm": "Withdraw request",
"confirmations.withdraw_request.title": "Withdraw request to follow {name}?",
"content_warning.hide": "Hide post",
"content_warning.show": "Show anyway",
"content_warning.show_more": "Show more",
@@ -920,6 +923,8 @@
"status.quote_private": "Private posts cannot be quoted",
"status.quotes": "{count, plural, one {quote} other {quotes}}",
"status.quotes.empty": "No one has quoted this post yet. When someone does, it will show up here.",
"status.quotes.local_other_disclaimer": "Quotes rejected by the author will not be shown.",
"status.quotes.remote_other_disclaimer": "Only quotes from {domain} are guaranteed to be shown here. Quotes rejected by the author will not be shown.",
"status.read_more": "Read more",
"status.reblog": "Boost",
"status.reblog_or_quote": "Boost or quote",

View File

@@ -248,8 +248,6 @@
"confirmations.revoke_quote.message": "Ĉi tiu ago ne povas esti malfarita.",
"confirmations.revoke_quote.title": "Ĉu forigi afiŝon?",
"confirmations.unfollow.confirm": "Ne plu sekvi",
"confirmations.unfollow.message": "Ĉu vi certas, ke vi volas ĉesi sekvi {name}?",
"confirmations.unfollow.title": "Ĉu ĉesi sekvi uzanton?",
"content_warning.hide": "Kaŝi afiŝon",
"content_warning.show": "Montri ĉiukaze",
"content_warning.show_more": "Montri pli",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Eliminar mensaje",
"confirmations.revoke_quote.message": "Esta acción no se puede deshacer.",
"confirmations.revoke_quote.title": "¿Eliminar mensaje?",
"confirmations.unblock.confirm": "Desbloquear",
"confirmations.unblock.title": "¿Desbloquear a {name}?",
"confirmations.unfollow.confirm": "Dejar de seguir",
"confirmations.unfollow.message": "¿Estás seguro que querés dejar de seguir a {name}?",
"confirmations.unfollow.title": "¿Dejar de seguir al usuario?",
"confirmations.unfollow.title": "¿Dejar de seguir a {name}?",
"confirmations.withdraw_request.confirm": "Retirar solicitud",
"confirmations.withdraw_request.title": "¿Retirar solicitud de seguimiento a {name}?",
"content_warning.hide": "Ocultar mensaje",
"content_warning.show": "Mostrar de todos modos",
"content_warning.show_more": "Mostrar más",
@@ -920,6 +923,8 @@
"status.quote_private": "No se pueden citar los mensajes privados",
"status.quotes": "{count, plural, one {# voto} other {# votos}}",
"status.quotes.empty": "Todavía nadie citó este mensaje. Cuando alguien lo haga, se mostrará acá.",
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
"status.read_more": "Leé más",
"status.reblog": "Adherir",
"status.reblog_or_quote": "Adherir o citar",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Eliminar publicación",
"confirmations.revoke_quote.message": "Esta acción no se puede deshacer.",
"confirmations.revoke_quote.title": "¿Deseas eliminar la publicación?",
"confirmations.unblock.confirm": "Desbloquear",
"confirmations.unblock.title": "¿Desbloquear a {name}?",
"confirmations.unfollow.confirm": "Dejar de seguir",
"confirmations.unfollow.message": "¿Estás seguro de que quieres dejar de seguir a {name}?",
"confirmations.unfollow.title": "¿Dejar de seguir al usuario?",
"confirmations.unfollow.title": "¿Dejar de seguir a {name}?",
"confirmations.withdraw_request.confirm": "Retirar solicitud",
"confirmations.withdraw_request.title": "¿Retirar solicitud de seguimiento a {name}?",
"content_warning.hide": "Ocultar publicación",
"content_warning.show": "Mostrar de todos modos",
"content_warning.show_more": "Mostrar más",
@@ -920,6 +923,8 @@
"status.quote_private": "Las publicaciones privadas no pueden citarse",
"status.quotes": "{count, plural,one {cita} other {citas}}",
"status.quotes.empty": "Nadie ha citado esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
"status.read_more": "Leer más",
"status.reblog": "Impulsar",
"status.reblog_or_quote": "Impulsar o citar",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Eliminar publicación",
"confirmations.revoke_quote.message": "Esta acción no tiene vuelta atrás.",
"confirmations.revoke_quote.title": "¿Eliminar la publicación?",
"confirmations.unblock.confirm": "Desbloquear",
"confirmations.unblock.title": "¿Desbloquear a {name}?",
"confirmations.unfollow.confirm": "Dejar de seguir",
"confirmations.unfollow.message": "¿Seguro que quieres dejar de seguir a {name}?",
"confirmations.unfollow.title": "¿Dejar de seguir al usuario?",
"confirmations.unfollow.title": "¿Dejar de seguir a {name}?",
"confirmations.withdraw_request.confirm": "Retirar solicitud",
"confirmations.withdraw_request.title": "¿Retirar solicitud de seguimiento a {name}?",
"content_warning.hide": "Ocultar publicación",
"content_warning.show": "Mostrar de todos modos",
"content_warning.show_more": "Mostrar más",
@@ -920,6 +923,8 @@
"status.quote_private": "Las publicaciones privadas no pueden ser citadas",
"status.quotes": "{count, plural,one {cita} other {citas}}",
"status.quotes.empty": "Nadie ha citado esta publicación todavía. Cuando alguien lo haga, aparecerá aquí.",
"status.quotes.local_other_disclaimer": "Las citas rechazadas por el autor no se mostrarán.",
"status.quotes.remote_other_disclaimer": "Solo se muestran las citas de {domain}. Las citas rechazadas por el autor no se mostrarán.",
"status.read_more": "Leer más",
"status.reblog": "Impulsar",
"status.reblog_or_quote": "Impulsar o citar",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Seda tegevust ei saa tagasi pöörata.",
"confirmations.revoke_quote.title": "Kas eemaldame postituse?",
"confirmations.unfollow.confirm": "Ära jälgi",
"confirmations.unfollow.message": "Oled kindel, et ei soovi rohkem jälgida kasutajat {name}?",
"confirmations.unfollow.title": "Ei jälgi enam kasutajat?",
"content_warning.hide": "Peida postitus",
"content_warning.show": "Näita ikkagi",
"content_warning.show_more": "Näita rohkem",

View File

@@ -240,8 +240,6 @@
"confirmations.remove_from_followers.message": "{name}-k zu jarraitzeari utziko dio. Seguru zaude jarraitu nahi duzula?",
"confirmations.remove_from_followers.title": "Jarraitzailea kendu nahi duzu?",
"confirmations.unfollow.confirm": "Utzi jarraitzeari",
"confirmations.unfollow.message": "Ziur {name} jarraitzeari utzi nahi diozula?",
"confirmations.unfollow.title": "Erabiltzailea jarraitzeari utzi?",
"content_warning.hide": "Tuta ezkutatu",
"content_warning.show": "Erakutsi hala ere",
"content_warning.show_more": "Erakutsi gehiago",

View File

@@ -248,8 +248,6 @@
"confirmations.revoke_quote.message": "این اقدام قابل بازگشت نیست.",
"confirmations.revoke_quote.title": "آیا فرسته را حذف کنم؟",
"confirmations.unfollow.confirm": "پی‌نگرفتن",
"confirmations.unfollow.message": "مطمئنید که می‌خواهید به پی‌گیری از {name} پایان دهید؟",
"confirmations.unfollow.title": "ناپی‌گیری کاربر؟",
"content_warning.hide": "نهفتن فرسته",
"content_warning.show": "در هر صورت نشان داده شود",
"content_warning.show_more": "نمایش بیش‌تر",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Poista julkaisu",
"confirmations.revoke_quote.message": "Tätä toimea ei voi peruuttaa.",
"confirmations.revoke_quote.title": "Poistetaanko julkaisu?",
"confirmations.unblock.confirm": "Kumoa esto",
"confirmations.unblock.title": "Kumotaanko käyttäjän {name} esto?",
"confirmations.unfollow.confirm": "Lopeta seuraaminen",
"confirmations.unfollow.message": "Haluatko varmasti lopettaa profiilin {name} seuraamisen?",
"confirmations.unfollow.title": "Lopetetaanko käyttäjän seuraaminen?",
"confirmations.unfollow.title": "Lopetetaanko käyttäjän {name} seuraaminen?",
"confirmations.withdraw_request.confirm": "Peruuta pyyntö",
"confirmations.withdraw_request.title": "Peruutetaanko pyyntö seurata käyttäjää {name}?",
"content_warning.hide": "Piilota julkaisu",
"content_warning.show": "Näytä kuitenkin",
"content_warning.show_more": "Näytä lisää",
@@ -920,6 +923,8 @@
"status.quote_private": "Yksityisiä julkaisuja ei voi lainata",
"status.quotes": "{count, plural, one {lainaus} other {lainausta}}",
"status.quotes.empty": "Kukaan ei ole vielä lainannut tätä julkaisua. Kun joku tekee niin, se tulee tähän näkyviin.",
"status.quotes.local_other_disclaimer": "Tekijän hylkäämiä lainauksia ei näytetä.",
"status.quotes.remote_other_disclaimer": "Vain palvelimen {domain} lainaukset näkyvät taatusti tässä. Tekijän hylkäämiä lainauksia ei näytetä.",
"status.read_more": "Näytä enemmän",
"status.reblog": "Tehosta",
"status.reblog_or_quote": "Tehosta tai lainaa",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Hendan atgerðin kann ikki angrast.",
"confirmations.revoke_quote.title": "Strika post?",
"confirmations.unfollow.confirm": "Fylg ikki",
"confirmations.unfollow.message": "Ert tú vís/ur í, at tú vil steðga við at fylgja {name}?",
"confirmations.unfollow.title": "Gevst at fylgja brúkara?",
"content_warning.hide": "Fjal post",
"content_warning.show": "Vís kortini",
"content_warning.show_more": "Vís meiri",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
"confirmations.revoke_quote.title": "Retirer la publication ?",
"confirmations.unfollow.confirm": "Ne plus suivre",
"confirmations.unfollow.message": "Voulez-vous vraiment arrêter de suivre {name}?",
"confirmations.unfollow.title": "Se désabonner de l'utilisateur·rice ?",
"content_warning.hide": "Masquer le message",
"content_warning.show": "Montrer quand même",
"content_warning.show_more": "Montrer plus",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.",
"confirmations.revoke_quote.title": "Retirer la publication ?",
"confirmations.unfollow.confirm": "Ne plus suivre",
"confirmations.unfollow.message": "Voulez-vous vraiment vous désabonner de {name}?",
"confirmations.unfollow.title": "Se désabonner de l'utilisateur·rice ?",
"content_warning.hide": "Masquer le message",
"content_warning.show": "Montrer quand même",
"content_warning.show_more": "Montrer plus",

View File

@@ -245,8 +245,6 @@
"confirmations.remove_from_followers.message": "{name} sil jo net mear folgje. Binne jo wis dat jo trochgean wolle?",
"confirmations.remove_from_followers.title": "Folger fuortsmite?",
"confirmations.unfollow.confirm": "Net mear folgje",
"confirmations.unfollow.message": "Binne jo wis dat jo {name} net mear folgje wolle?",
"confirmations.unfollow.title": "Brûker net mear folgje?",
"content_warning.hide": "Berjocht ferstopje",
"content_warning.show": "Dochs toane",
"content_warning.show_more": "Mear toane",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Ní féidir an gníomh seo a chealú.",
"confirmations.revoke_quote.title": "Bain postáil?",
"confirmations.unfollow.confirm": "Ná lean",
"confirmations.unfollow.message": "An bhfuil tú cinnte gur mhaith leat {name} a dhíleanúint?",
"confirmations.unfollow.title": "Dílean an t-úsáideoir?",
"content_warning.hide": "Folaigh postáil",
"content_warning.show": "Taispeáin ar aon nós",
"content_warning.show_more": "Taispeáin níos mó",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "Cha ghabh seo a neo-dhèanamh.",
"confirmations.revoke_quote.title": "A bheil thu airson am post a thoirt air falbh?",
"confirmations.unfollow.confirm": "Na lean tuilleadh",
"confirmations.unfollow.message": "A bheil thu cinnteach nach eil thu airson {name} a leantainn tuilleadh?",
"confirmations.unfollow.title": "A bheil thu airson sgur de leantainn a chleachdaiche?",
"content_warning.hide": "Falaich am post",
"content_warning.show": "Seall e co-dhiù",
"content_warning.show_more": "Seall barrachd dheth",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Eliminar publicación",
"confirmations.revoke_quote.message": "Esta acción non se pode desfacer.",
"confirmations.revoke_quote.title": "Eliminar publicación?",
"confirmations.unblock.confirm": "Desbloquear",
"confirmations.unblock.title": "Desbloquear a {name}?",
"confirmations.unfollow.confirm": "Deixar de seguir",
"confirmations.unfollow.message": "Tes certeza de querer deixar de seguir a {name}?",
"confirmations.unfollow.title": "Deixar de seguir á usuaria?",
"confirmations.unfollow.title": "Deixa de seguir a {name}?",
"confirmations.withdraw_request.confirm": "Retirar solicitude",
"confirmations.withdraw_request.title": "Retirar a petición de seguimento para {name}?",
"content_warning.hide": "Agochar publicación",
"content_warning.show": "Mostrar igualmente",
"content_warning.show_more": "Mostrar máis",
@@ -920,6 +923,8 @@
"status.quote_private": "As publicacións privadas non se poden citar",
"status.quotes": "{count, plural, one {cita} other {citas}}",
"status.quotes.empty": "Aínda ninguén citou esta publicación. Cando alguén o faga aparecerá aquí.",
"status.quotes.local_other_disclaimer": "Non se mostrarán as citas rexeitadas pola autora.",
"status.quotes.remote_other_disclaimer": "Só se garante que se mostren as citas do dominio {domain}. Non se mostrarán as citas rexeitadas pola persoa autora.",
"status.read_more": "Ler máis",
"status.reblog": "Promover",
"status.reblog_or_quote": "Promover ou citar",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "הסרת הודעה",
"confirmations.revoke_quote.message": "פעולה זו אינה הפיכה.",
"confirmations.revoke_quote.title": "הסרת הודעה?",
"confirmations.unblock.confirm": "הסרת חסימה",
"confirmations.unblock.title": "הסרת חסימה מ־{name}?",
"confirmations.unfollow.confirm": "הפסקת מעקב",
"confirmations.unfollow.message": הפסיק מעקב אחרי {name}?",
"confirmations.unfollow.title": "לבטל מעקב אחר המשתמש.ת?",
"confirmations.unfollow.title": "בטול מעקב אחרי {name}?",
"confirmations.withdraw_request.confirm": "משיכת בקשה",
"confirmations.withdraw_request.title": "משיכת בקשת מעקב אחרי {name}?",
"content_warning.hide": "הסתרת חיצרוץ",
"content_warning.show": "להציג בכל זאת",
"content_warning.show_more": "הצג עוד",
@@ -920,6 +923,8 @@
"status.quote_private": "הודעות פרטיות לא ניתנות לציטוט",
"status.quotes": "{count, plural,one {ציטוט}other {ציטוטים}}",
"status.quotes.empty": "עוד לא ציטטו את ההודעה הזו. כאשר זה יקרה, הציטוטים יופיעו כאן.",
"status.quotes.local_other_disclaimer": "ציטוטים שיידחו על ידי המחברים המקוריים לא יוצגו.",
"status.quotes.remote_other_disclaimer": "רק ציטוטים מהשרת {domain} מובטחים שיופיעו פה. ציטוטים שנדחו על ידי המצוטטים לא יופיעו.",
"status.read_more": "לקרוא עוד",
"status.reblog": "הדהוד",
"status.reblog_or_quote": "להדהד או לצטט",

View File

@@ -168,7 +168,6 @@
"confirmations.redraft.confirm": "मिटायें और पुनःप्रारूपण करें",
"confirmations.redraft.message": "क्या आप वाकई इस स्टेटस को हटाना चाहते हैं और इसे फिर से ड्राफ्ट करना चाहते हैं? पसंदीदा और बूस्ट खो जाएंगे, और मूल पोस्ट के उत्तर अनाथ हो जाएंगे।",
"confirmations.unfollow.confirm": "अनफॉलो करें",
"confirmations.unfollow.message": "क्या आप वाकई {name} को अनफॉलो करना चाहते हैं?",
"conversation.delete": "वार्तालाप हटाएँ",
"conversation.mark_as_read": "पढ़ा गया के रूप में चिह्नित करें",
"conversation.open": "वार्तालाप देखें",

View File

@@ -146,7 +146,6 @@
"confirmations.mute.confirm": "Utišaj",
"confirmations.redraft.confirm": "Izbriši i ponovno uredi",
"confirmations.unfollow.confirm": "Prestani pratiti",
"confirmations.unfollow.message": "Jeste li sigurni da želite prestati pratiti {name}?",
"conversation.delete": "Izbriši razgovor",
"conversation.mark_as_read": "Označi kao pročitano",
"conversation.open": "Prikaži razgovor",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "Ez a művelet nem vonható vissza.",
"confirmations.revoke_quote.title": "Bejegyzés eltávolítása?",
"confirmations.unfollow.confirm": "Követés visszavonása",
"confirmations.unfollow.message": "Biztos, hogy vissza szeretnéd vonni {name} követését?",
"confirmations.unfollow.title": "Megszünteted a felhasználó követését?",
"content_warning.hide": "Bejegyzés elrejtése",
"content_warning.show": "Megjelenítés mindenképp",
"content_warning.show_more": "Több megjelenítése",

View File

@@ -124,7 +124,6 @@
"confirmations.mute.confirm": "Լռեցնել",
"confirmations.redraft.confirm": "Ջնջել եւ խմբագրել նորից",
"confirmations.unfollow.confirm": "Ապահետեւել",
"confirmations.unfollow.message": "Վստա՞հ ես, որ ուզում ես այլեւս չհետեւել {name}֊ին։",
"conversation.delete": "Ջնջել խօսակցութիւնը",
"conversation.mark_as_read": "Նշել որպէս ընթերցուած",
"conversation.open": "Դիտել խօսակցութիւնը",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Iste action non pote esser disfacite.",
"confirmations.revoke_quote.title": "Remover message?",
"confirmations.unfollow.confirm": "Non plus sequer",
"confirmations.unfollow.message": "Es tu secur que tu vole cessar de sequer {name}?",
"confirmations.unfollow.title": "Cessar de sequer le usator?",
"content_warning.hide": "Celar le message",
"content_warning.show": "Monstrar in omne caso",
"content_warning.show_more": "Monstrar plus",

View File

@@ -189,8 +189,6 @@
"confirmations.redraft.message": "Apakah anda yakin ingin menghapus postingan ini dan menyusun ulang postingan ini? Favorit dan peningkatan akan hilang, dan balasan ke postingan asli tidak akan terhubung ke postingan manapun.",
"confirmations.redraft.title": "Delete & redraft post?",
"confirmations.unfollow.confirm": "Berhenti mengikuti",
"confirmations.unfollow.message": "Apakah Anda ingin berhenti mengikuti {name}?",
"confirmations.unfollow.title": "Unfollow user?",
"content_warning.hide": "Hide post",
"content_warning.show": "Show anyway",
"conversation.delete": "Hapus percakapan",

View File

@@ -168,7 +168,6 @@
"confirmations.redraft.confirm": "Deleter & redacter",
"confirmations.redraft.message": "Esque tu vermen vole deleter ti-ci posta e redacter it? Favorites e boosts va esser perdit, e responses al posta original va esser orfanat.",
"confirmations.unfollow.confirm": "Dessequer",
"confirmations.unfollow.message": "Esque tu vermen vole dessequer {name}?",
"conversation.delete": "Deleter conversation",
"conversation.mark_as_read": "Marcar quam leet",
"conversation.open": "Vider conversation",

View File

@@ -218,8 +218,6 @@
"confirmations.redraft.message": "Ka vu certe volas efacar ca posto e riskisigar ol? Favoriziti e repeti esos perdita, e respondi al posto originala esos orfanigita.",
"confirmations.redraft.title": "Ka efacar & riskisar posto?",
"confirmations.unfollow.confirm": "Desequez",
"confirmations.unfollow.message": "Ka vu certe volas desequar {name}?",
"confirmations.unfollow.title": "Ka dessequar uzanto?",
"content_warning.hide": "Celez posto",
"content_warning.show": "Montrez nur",
"content_warning.show_more": "Montrar plu",

View File

@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Fjarlægja færslu",
"confirmations.revoke_quote.message": "Þessa aðgerð er ekki hægt að afturkalla.",
"confirmations.revoke_quote.title": "Fjarlægja færslu?",
"confirmations.unblock.confirm": "Aflétta útilokun",
"confirmations.unblock.title": "Aflétta útilokun á {name}?",
"confirmations.unfollow.confirm": "Hætta að fylgja",
"confirmations.unfollow.message": "Ertu viss um að þú viljir hætta að fylgjast með {name}?",
"confirmations.unfollow.title": "Hætta að fylgjast með viðkomandi?",
"confirmations.unfollow.title": "Hætta að fylgjast með {name}?",
"confirmations.withdraw_request.confirm": "Taka beiðni til baka",
"confirmations.withdraw_request.title": "Taka aftur beiðni um að fylgjast með {name}?",
"content_warning.hide": "Fela færslu",
"content_warning.show": "Birta samt",
"content_warning.show_more": "Sýna meira",
@@ -920,6 +923,8 @@
"status.quote_private": "Ekki er hægt að vitna í einkafærslur",
"status.quotes": "{count, plural, one {tilvitnun} other {tilvitnanir}}",
"status.quotes.empty": "Enginn hefur ennþá vitnað í þessa færslu. Þegar einhver gerir það, mun það birtast hér.",
"status.quotes.local_other_disclaimer": "Tilvitnanir sem höfundur hafnar verða ekki birtar.",
"status.quotes.remote_other_disclaimer": "Aðeins tilvitnanir frá {domain} munu birtast hér. Tilvitnanir sem höfundur hafnar verða ekki birtar.",
"status.read_more": "Lesa meira",
"status.reblog": "Endurbirting",
"status.reblog_or_quote": "Endurbirta eða vitna í færslu",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "Questa azione non può essere annullata.",
"confirmations.revoke_quote.title": "Rimuovere il post?",
"confirmations.unfollow.confirm": "Smetti di seguire",
"confirmations.unfollow.message": "Sei sicuro di voler smettere di seguire {name}?",
"confirmations.unfollow.title": "Smettere di seguire l'utente?",
"content_warning.hide": "Nascondi post",
"content_warning.show": "Mostra comunque",
"content_warning.show_more": "Mostra di più",

View File

@@ -248,8 +248,6 @@
"confirmations.revoke_quote.confirm": "投稿を削除",
"confirmations.revoke_quote.title": "投稿を削除しますか?",
"confirmations.unfollow.confirm": "フォロー解除",
"confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?",
"confirmations.unfollow.title": "フォローを解除しようとしています",
"content_warning.hide": "内容を隠す",
"content_warning.show": "承知して表示",
"content_warning.show_more": "続きを表示",

View File

@@ -71,7 +71,6 @@
"confirmations.mute.confirm": "დადუმება",
"confirmations.redraft.confirm": "გაუქმება და გადანაწილება",
"confirmations.unfollow.confirm": "ნუღარ მიჰყვები",
"confirmations.unfollow.message": "დარწმუნებული ხართ, აღარ გსურთ მიჰყვებოდეთ {name}-ს?",
"embed.instructions": "ეს სტატუსი ჩასვით თქვენს ვებ-საიტზე შემდეგი კოდის კოპირებით.",
"embed.preview": "ესაა თუ როგორც გამოჩნდება:",
"emoji_button.activity": "აქტივობა",

View File

@@ -194,7 +194,6 @@
"confirmations.revoke_quote.confirm": "Kkes tasuffeɣt",
"confirmations.revoke_quote.title": "Kkes tasuffeɣt?",
"confirmations.unfollow.confirm": "Ur ḍḍafaṛ ara",
"confirmations.unfollow.message": "Tetḥeqqeḍ belli tebɣiḍ ur teṭafaṛeḍ ara {name}?",
"content_warning.hide": "Ffer tasuffeɣt",
"content_warning.show": "Ssken-d akken tebɣu tili",
"content_warning.show_more": "Sken-d ugar",

View File

@@ -140,7 +140,6 @@
"confirmations.mute.confirm": "Үнсіз қылу",
"confirmations.redraft.confirm": "Өшіруді құптау",
"confirmations.unfollow.confirm": "Оқымау",
"confirmations.unfollow.message": "\"{name} атты қолданушыға енді жазылғыңыз келмей ме?",
"conversation.delete": "Пікірталасты өшіру",
"conversation.mark_as_read": "Оқылды деп белгіле",
"conversation.open": "Пікірталасты қарау",

View File

@@ -252,8 +252,6 @@
"confirmations.revoke_quote.message": "이 작업은 되돌릴 수 없습니다.",
"confirmations.revoke_quote.title": "게시물을 지울까요?",
"confirmations.unfollow.confirm": "팔로우 해제",
"confirmations.unfollow.message": "정말로 {name} 님을 팔로우 해제하시겠습니까?",
"confirmations.unfollow.title": "사용자를 언팔로우 할까요?",
"content_warning.hide": "게시물 숨기기",
"content_warning.show": "무시하고 보기",
"content_warning.show_more": "더 보기",

View File

@@ -169,7 +169,6 @@
"confirmations.redraft.confirm": "Jê bibe & ji nû ve serrast bike",
"confirmations.redraft.message": "Bi rastî tu dixwazî şandî ye jê bibî û ji nû ve reşnivîsek çê bikî? Bijarte û şandî wê wenda bibin û bersivên ji bo şandiyê resen wê sêwî bimînin.",
"confirmations.unfollow.confirm": "Neşopîne",
"confirmations.unfollow.message": "Ma tu dixwazî ku dev ji şopa {name} berdî?",
"content_warning.show_more": "Bêtir nîşan bide",
"conversation.delete": "Axaftinê jê bibe",
"conversation.mark_as_read": "Wekî xwendî nîşan bide",

View File

@@ -86,7 +86,6 @@
"confirmations.mute.confirm": "Tawhe",
"confirmations.redraft.confirm": "Dilea & daskynskrifa",
"confirmations.unfollow.confirm": "Anholya",
"confirmations.unfollow.message": "Owgh hwi sur a vynnes anholya {name}?",
"conversation.delete": "Dilea kesklapp",
"conversation.mark_as_read": "Merkya vel redys",
"conversation.open": "Gweles kesklapp",

View File

@@ -214,8 +214,6 @@
"confirmations.revoke_quote.confirm": "Kita puvlikasyon",
"confirmations.revoke_quote.title": "Kitar puvlikasyon?",
"confirmations.unfollow.confirm": "Desige",
"confirmations.unfollow.message": "Estas siguro ke keres deshar de segir a {name}?",
"confirmations.unfollow.title": "Desige utilizador?",
"content_warning.hide": "Eskonde puvlikasyon",
"content_warning.show": "Amostra entanto",
"content_warning.show_more": "Amostra mas",

View File

@@ -232,8 +232,6 @@
"confirmations.remove_from_followers.message": "{name} nustos jus sekti. Ar tikrai norite tęsti?",
"confirmations.remove_from_followers.title": "Šalinti sekėją?",
"confirmations.unfollow.confirm": "Nebesekti",
"confirmations.unfollow.message": "Ar tikrai nori nebesekti {name}?",
"confirmations.unfollow.title": "Nebesekti naudotoją?",
"content_warning.hide": "Slėpti įrašą",
"content_warning.show": "Rodyti vis tiek",
"content_warning.show_more": "Rodyti daugiau",

View File

@@ -243,8 +243,6 @@
"confirmations.revoke_quote.message": "Šo darbību nevar atsaukt.",
"confirmations.revoke_quote.title": "Noņemt ierakstu?",
"confirmations.unfollow.confirm": "Pārstāt sekot",
"confirmations.unfollow.message": "Vai tiešam vairs nevēlies sekot lietotājam {name}?",
"confirmations.unfollow.title": "Pārtraukt sekošanu lietotājam?",
"content_warning.hide": "Paslēpt ierakstu",
"content_warning.show": "Tomēr rādīt",
"content_warning.show_more": "Rādīt vairāk",

View File

@@ -82,7 +82,6 @@
"confirmations.logout.message": "Дали сте сигурни дека сакате да се одјавите?",
"confirmations.mute.confirm": "Заќути",
"confirmations.unfollow.confirm": "Одследи",
"confirmations.unfollow.message": "Сигурни сте дека ќе го отследите {name}?",
"conversation.delete": "Избриши разговор",
"conversation.mark_as_read": "Означете како прочитано",
"conversation.open": "Прегледај разговор",

View File

@@ -139,7 +139,6 @@
"confirmations.mute.confirm": "നിശ്ശബ്ദമാക്കുക",
"confirmations.redraft.confirm": "മായിച്ച് മാറ്റങ്ങൾ വരുത്തി വീണ്ടും എഴുതുക",
"confirmations.unfollow.confirm": "പിന്തുടരുന്നത് നിര്‍ത്തുക",
"confirmations.unfollow.message": "നിങ്ങൾ {name} യെ പിന്തുടരുന്നത് നിർത്തുവാൻ തീർച്ചയായും തീരുമാനിച്ചുവോ?",
"conversation.delete": "സംഭാഷണം മായിക്കുക",
"conversation.mark_as_read": "വായിച്ചതായി അടയാളപ്പെടുത്തുക",
"conversation.open": "സംഭാഷണം കാണുക",

View File

@@ -222,8 +222,6 @@
"confirmations.redraft.message": "Adakah anda pasti anda ingin memadam hantaran ini dan gubal semula? Sukaan dan galakan akan hilang, dan balasan ke hantaran asal akan menjadi yatim.",
"confirmations.redraft.title": "Padam & gubah semula hantaran?",
"confirmations.unfollow.confirm": "Nyahikut",
"confirmations.unfollow.message": "Adakah anda pasti anda ingin nyahikuti {name}?",
"confirmations.unfollow.title": "Berhenti mengikut pengguna?",
"content_warning.hide": "Sorok hantaran",
"content_warning.show": "Tunjuk saja",
"content_warning.show_more": "Tunjuk lebih",

View File

@@ -152,7 +152,6 @@
"confirmations.redraft.confirm": "ဖျက်ပြီး ပြန်လည်ရေးမည်။",
"confirmations.redraft.message": "သင် ဒီပိုစ့်ကိုဖျက်ပြီး ပြန်တည်းဖြတ်မှာ သေချာပြီလား။ ကြယ်ပွင့်​တွေ နဲ့ ပြန်မျှ​ဝေမှု​တွေကိုဆုံးရှုံးမည်။မူရင်းပို့စ်ဆီကို ပြန်စာ​တွေမှာလည်း​ \nပိုစ့်ကိုတွေ့ရမည်မဟုတ်တော့ပါ။.",
"confirmations.unfollow.confirm": "စောင့်ကြည့်ခြင်းအား ပယ်ဖျက်မည်",
"confirmations.unfollow.message": "{name} ကို စောင်ကြည့်ခြင်းအား ပယ်ဖျက်မည်မှာသေချာပါသလား။",
"conversation.delete": "ဤစကားပြောဆိုမှုကို ဖျက်ပစ်မည်",
"conversation.mark_as_read": "ဖတ်ပြီးသားအဖြစ်မှတ်ထားပါ",
"conversation.open": "Conversation ကိုကြည့်မည်",

View File

@@ -258,8 +258,6 @@
"confirmations.revoke_quote.message": "Tsit ê動作bē當復原。",
"confirmations.revoke_quote.title": "Kám beh thâi掉PO文",
"confirmations.unfollow.confirm": "取消跟tuè",
"confirmations.unfollow.message": "Lí kám確定無愛跟tuè {name}",
"confirmations.unfollow.title": "Kám beh取消跟tuè tsit ê用者?",
"content_warning.hide": "Am-khàm PO文",
"content_warning.show": "Mā tio̍h顯示",
"content_warning.show_more": "其他內容",

View File

@@ -143,8 +143,6 @@
"confirmations.redraft.confirm": "मेटाएर पुन: ड्राफ्ट गर्नुहोस्",
"confirmations.redraft.title": "पोस्ट मेटाएर पुन: ड्राफ्ट गर्ने?",
"confirmations.unfollow.confirm": "अनफलो गर्नुहोस्",
"confirmations.unfollow.message": "के तपाइँ पक्का हुनुहुन्छ कि तपाइँ {name}लाई अनफलो गर्न चाहनुहुन्छ?",
"confirmations.unfollow.title": "प्रयोगकर्तालाई अनफलो गर्ने?",
"disabled_account_banner.account_settings": "खाता सेटिङहरू",
"empty_column.direct": "तपाईंले अहिलेसम्म कुनै पनि प्राइवेट उल्लेखहरू प्राप्त गर्नुभएको छैन। तपाईंले कुनै प्राप्त गरेपछि त्यो यहाँ देखिनेछ।",
"empty_column.follow_requests": "तपाईंले अहिलेसम्म कुनै पनि फलो अनुरोधहरू प्राप्त गर्नुभएको छैन। तपाईंले कुनै प्राप्त गरेपछि त्यो यहाँ देखिनेछ।",

View File

@@ -42,7 +42,7 @@
"account.follow": "Volgen",
"account.follow_back": "Terugvolgen",
"account.follow_back_short": "Terugvolgen",
"account.follow_request": "Verzoeken om te volgen",
"account.follow_request": "Volgverzoek",
"account.follow_request_cancel": "Verzoek annuleren",
"account.follow_request_cancel_short": "Annuleren",
"account.follow_request_short": "Verzoek",
@@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Bericht verwijderen",
"confirmations.revoke_quote.message": "Deze actie kan niet ongedaan worden gemaakt.",
"confirmations.revoke_quote.title": "Bericht verwijderen?",
"confirmations.unblock.confirm": "Deblokkeren",
"confirmations.unblock.title": "{name} deblokkeren?",
"confirmations.unfollow.confirm": "Ontvolgen",
"confirmations.unfollow.message": "Weet je het zeker dat je {name} wilt ontvolgen?",
"confirmations.unfollow.title": "Gebruiker ontvolgen?",
"confirmations.unfollow.title": "{name} ontvolgen?",
"confirmations.withdraw_request.confirm": "Verzoek intrekken",
"confirmations.withdraw_request.title": "Verzoek intrekken om {name} te volgen?",
"content_warning.hide": "Bericht verbergen",
"content_warning.show": "Alsnog tonen",
"content_warning.show_more": "Meer tonen",
@@ -920,6 +923,8 @@
"status.quote_private": "Citeren van berichten aan alleen volgers is niet mogelijk",
"status.quotes": "{count, plural, one {citaat} other {citaten}}",
"status.quotes.empty": "Niemand heeft dit bericht nog geciteerd. Wanneer iemand dat doet, wordt dat hier getoond.",
"status.quotes.local_other_disclaimer": "Citaten afgewezen door de auteur worden niet getoond.",
"status.quotes.remote_other_disclaimer": "Alleen citaten van {domain} worden hier gegarandeerd weergegeven. Citaten afgewezen door de auteur worden niet getoond.",
"status.read_more": "Meer lezen",
"status.reblog": "Boosten",
"status.reblog_or_quote": "Boosten of citeren",

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