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

Merge upstream changes up to f69ca085dbfca2253404574dcdc4dc6c2aaa35c0
This commit is contained in:
Claire 2025-10-02 10:05:07 +02:00 committed by GitHub
commit ade87f395c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
189 changed files with 2130 additions and 1446 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! :read, :'read:statuses' }, only: :index
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke 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 before_action :set_quote, only: :revoke
after_action :insert_pagination_headers, only: :index after_action :insert_pagination_headers, only: :index
def index def index
cache_if_unauthenticated! cache_if_unauthenticated!
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer render json: @statuses, each_serializer: REST::StatusSerializer
end end
@ -24,18 +24,26 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
private private
def check_owner!
authorize @status, :list_quotes?
end
def set_quote def set_quote
@quote = @status.quotes.find_by!(status_id: params[:id]) @quote = @status.quotes.find_by!(status_id: params[:id])
end end
def load_statuses def set_statuses
scope = default_statuses scope = default_statuses
scope = scope.not_excluded_by_account(current_account) unless current_account.nil? 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 end
def default_statuses 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? api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
end end
def pagination_max_id attr_reader :pagination_max_id, :pagination_since_id
@statuses.last.quote.id
end
def pagination_since_id
@statuses.first.quote.id
end
def records_continue? def records_continue?
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) @records_continue
end end
end end

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import type { ComponentPropsWithoutRef, FC } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react';
import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { EmojiHTML } from '../emoji/html';
import type { DisplayNameProps } from './index'; import type { DisplayNameProps } from './index';
export const DisplayNameSimple: FC< export const DisplayNameSimple: FC<
@ -12,12 +13,19 @@ export const DisplayNameSimple: FC<
if (!account) { if (!account) {
return null; return null;
} }
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
return ( return (
<bdi> <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> </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 '@/flavours/glitch/features/emoji/normalize';
import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { polymorphicForwardRef } from '@/types/polymorphic';
import type {
CustomEmojiMapArg,
ExtraCustomEmojiMap,
} from 'flavours/glitch/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 '@/flavours/glitch/features/emoji/types';
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
import { htmlStringToComponents } from '@/flavours/glitch/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 '@/flavours/glitch/features/emoji/constants';
import { useEmojiAppState } from '@/flavours/glitch/features/emoji/hooks';
import { unicodeHexToUrl } from '@/flavours/glitch/features/emoji/normalize';
import {
isStateLoaded,
loadEmojiDataToState,
shouldRenderImage,
stringToEmojiState,
tokenizeText,
} from '@/flavours/glitch/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 '@/flavours/glitch/identity_context';
import { import {
fetchRelationships, fetchRelationships,
followAccount, followAccount,
unblockAccount,
unmuteAccount, unmuteAccount,
} from 'flavours/glitch/actions/accounts'; } from 'flavours/glitch/actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
@ -59,7 +58,8 @@ export const FollowButton: React.FC<{
accountId?: string; accountId?: string;
compact?: boolean; compact?: boolean;
labelLength?: 'auto' | 'short' | 'long'; labelLength?: 'auto' | 'short' | 'long';
}> = ({ accountId, compact, labelLength = 'auto' }) => { className?: string;
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { signedIn } = useIdentity(); const { signedIn } = useIdentity();
@ -96,12 +96,24 @@ export const FollowButton: React.FC<{
return; return;
} else if (relationship.muting) { } else if (relationship.muting) {
dispatch(unmuteAccount(accountId)); dispatch(unmuteAccount(accountId));
} else if (account && (relationship.following || relationship.requested)) { } else if (account && relationship.following) {
dispatch( dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
); );
} else if (account && relationship.requested) {
dispatch(
openModal({
modalType: 'CONFIRM_WITHDRAW_REQUEST',
modalProps: { account },
}),
);
} else if (relationship.blocking) { } else if (relationship.blocking) {
dispatch(unblockAccount(accountId)); dispatch(
openModal({
modalType: 'CONFIRM_UNBLOCK',
modalProps: { account },
}),
);
} else { } else {
dispatch(followAccount(accountId)); dispatch(followAccount(accountId));
} }
@ -144,7 +156,7 @@ export const FollowButton: React.FC<{
href='/settings/profile' href='/settings/profile'
target='_blank' target='_blank'
rel='noopener' rel='noopener'
className={classNames('button button-secondary', { className={classNames(className, 'button button-secondary', {
'button--compact': compact, 'button--compact': compact,
})} })}
> >
@ -158,13 +170,12 @@ export const FollowButton: React.FC<{
onClick={handleClick} onClick={handleClick}
disabled={ disabled={
relationship?.blocked_by || relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) && (!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved)) (account?.suspended || !!account?.moved))
} }
secondary={following} secondary={following}
compact={compact} compact={compact}
className={following ? 'button--destructive' : undefined} className={classNames(className, { 'button--destructive': following })}
> >
{label} {label}
</Button> </Button>

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
import { AccountBio } from '@/flavours/glitch/components/account_bio'; import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { DisplayName } from '@/flavours/glitch/components/display_name'; import { DisplayName } from '@/flavours/glitch/components/display_name';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
@ -37,7 +38,6 @@ import {
AutomatedBadge, AutomatedBadge,
GroupBadge, GroupBadge,
} from 'flavours/glitch/components/badge'; } from 'flavours/glitch/components/badge';
import { Button } from 'flavours/glitch/components/button';
import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button'; import { CopyIconButton } from 'flavours/glitch/components/copy_icon_button';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { FollowButton } from 'flavours/glitch/components/follow_button'; import { FollowButton } from 'flavours/glitch/components/follow_button';
@ -387,7 +387,7 @@ export const AccountHeader: React.FC<{
const isRemote = account?.acct !== account?.username; const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => { const menuItems = useMemo(() => {
const arr: MenuItem[] = []; const arr: MenuItem[] = [];
if (!account) { if (!account) {
@ -609,6 +609,15 @@ export const AccountHeader: React.FC<{
handleUnblockDomain, handleUnblockDomain,
]); ]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) { if (!account) {
return null; return null;
} }
@ -722,21 +731,16 @@ export const AccountHeader: React.FC<{
); );
} }
if (relationship?.blocking) { const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
actionBtn = ( actionBtn = (
<Button <FollowButton
text={intl.formatMessage(messages.unblock, { accountId={accountId}
name: account.username, className='account__header__follow-button'
})} labelLength='long'
onClick={handleBlock}
/> />
); );
} else {
actionBtn = <FollowButton accountId={accountId} />;
}
if (account.moved && !relationship?.following) {
actionBtn = '';
} }
if (account.locked) { if (account.locked) {
@ -781,8 +785,8 @@ export const AccountHeader: React.FC<{
<MovedNote accountId={account.id} targetAccountId={account.moved} /> <MovedNote accountId={account.id} targetAccountId={account.moved} />
)} )}
<div <AnimateEmojiProvider
className={classNames('account__header animate-parent', { className={classNames('account__header', {
inactive: !!account.moved, inactive: !!account.moved,
})} })}
> >
@ -818,18 +822,11 @@ export const AccountHeader: React.FC<{
/> />
</a> </a>
<div className='account__header__tabs__buttons'> <div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
{!hidden && bellBtn} {!hidden && bellBtn}
{!hidden && shareBtn} {!hidden && shareBtn}
{accountId !== me && ( {menu}
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
)}
{!hidden && actionBtn}
</div> </div>
</div> </div>
@ -859,6 +856,12 @@ export const AccountHeader: React.FC<{
<FamiliarFollowers accountId={accountId} /> <FamiliarFollowers accountId={accountId} />
)} )}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
{!(suspended || hidden) && ( {!(suspended || hidden) && (
<div className='account__header__extra'> <div className='account__header__extra'>
<div <div
@ -937,7 +940,7 @@ export const AccountHeader: React.FC<{
</div> </div>
)} )}
</div> </div>
</div> </AnimateEmojiProvider>
<ActionBar account={account} /> <ActionBar account={account} />

View File

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

View File

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

View File

@ -197,11 +197,18 @@ function toLoadedLocale(localeString: string) {
log(`Locale ${locale} is different from provided ${localeString}`); log(`Locale ${locale} is different from provided ${localeString}`);
} }
if (!loadedLocales.has(locale)) { if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`); throw new LocaleNotLoadedError(locale);
} }
return 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> { async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) { if (loadedLocales.has(locale)) {
return true; return true;

View File

@ -1,70 +0,0 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import classNames from 'classnames';
import { isModernEmojiEnabled } from '@/flavours/glitch/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 '@/flavours/glitch/initial_state'; import { initialState } from '@/flavours/glitch/initial_state';
import { loadWorker } from '@/flavours/glitch/utils/workers'; import { loadWorker } from '@/flavours/glitch/utils/workers';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';

View File

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

View File

@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json'; import unicodeRawEmojis from 'emojibase-data/en/data.json';
import { import {
twemojiHasBorder,
twemojiToUnicodeInfo, twemojiToUnicodeInfo,
unicodeToTwemojiHex, unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex, emojiToUnicodeHex,
} from './normalize'; } 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', () => { describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode)); const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));

View File

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

View File

@ -1,10 +1,6 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { import { EMOJI_MODE_TWEMOJI } from './constants';
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import * as db from './database'; import * as db from './database';
import { import {
emojifyElement, emojifyElement,
@ -12,7 +8,7 @@ import {
testCacheClear, testCacheClear,
tokenizeText, tokenizeText,
} from './render'; } from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types'; import type { EmojiAppState } from './types';
function mockDatabase() { function mockDatabase() {
return { return {
@ -40,18 +36,6 @@ const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">'; '<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage = const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">'; '<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> = {}) { function testAppState(state: Partial<EmojiAppState> = {}) {
return { return {
@ -86,64 +70,10 @@ describe('emojifyElement', () => {
'en', 'en',
); );
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([ 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 () => { test('returns null when no emoji are found', async () => {
mockDatabase(); mockDatabase();
const actual = await emojifyElement( const actual = await emojifyElement(
@ -165,28 +95,9 @@ describe('emojifyText', () => {
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState()); const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`); 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', () => { 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', () => { test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']); expect(tokenizeText('Hello')).toEqual(['Hello']);
}); });
@ -212,7 +123,7 @@ describe('tokenizeText', () => {
'Hello ', 'Hello ',
{ {
type: 'custom', type: 'custom',
code: 'smile', code: ':smile:',
}, },
'!!', '!!',
]); ]);
@ -223,7 +134,7 @@ describe('tokenizeText', () => {
'Hello ', 'Hello ',
{ {
type: 'custom', type: 'custom',
code: 'smile_123', code: ':smile_123:',
}, },
'!!', '!!',
]); ]);
@ -239,7 +150,7 @@ describe('tokenizeText', () => {
' ', ' ',
{ {
type: 'custom', type: 'custom',
code: 'smile', code: ':smile:',
}, },
'!!', '!!',
]); ]);

View File

@ -1,6 +1,5 @@
import { autoPlayGif } from '@/flavours/glitch/initial_state'; import { autoPlayGif } from '@/flavours/glitch/initial_state';
import { createLimitedCache } from '@/flavours/glitch/utils/cache'; import { createLimitedCache } from '@/flavours/glitch/utils/cache';
import { assetHost } from '@/flavours/glitch/utils/config';
import * as perf from '@/flavours/glitch/utils/performance'; import * as perf from '@/flavours/glitch/utils/performance';
import { import {
@ -8,38 +7,130 @@ import {
EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE, EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM, EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants'; } from './constants';
import { import {
loadCustomEmojiByShortcode,
loadEmojiByHexcode,
LocaleNotLoadedError,
searchCustomEmojisByShortcodes, searchCustomEmojisByShortcodes,
searchEmojisByHexcodes, searchEmojisByHexcodes,
} from './database'; } from './database';
import { import { importEmojiData } from './loader';
emojiToUnicodeHex, import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import type { import type {
CustomEmojiToken,
EmojiAppState, EmojiAppState,
EmojiLoadedState, EmojiLoadedState,
EmojiMode, EmojiMode,
EmojiState, EmojiState,
EmojiStateCustom,
EmojiStateMap, EmojiStateMap,
EmojiToken, EmojiStateUnicode,
ExtraCustomEmojiMap, ExtraCustomEmojiMap,
LocaleOrCustom, LocaleOrCustom,
UnicodeEmojiToken,
} from './types'; } from './types';
import { import {
anyEmojiRegex, anyEmojiRegex,
emojiLogger, emojiLogger,
isCustomEmoji,
isUnicodeEmoji,
stringHasAnyEmoji, stringHasAnyEmoji,
stringHasUnicodeFlags, stringHasUnicodeFlags,
} from './utils'; } from './utils';
const log = emojiLogger('render'); 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. * 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) { if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code]; const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) { if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData }; state = {
type: EMOJI_TYPE_CUSTOM,
data: extraEmojiData,
code: token.code,
};
} else { } else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM); 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 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); const image = stateToImage(state, appState);
renderedFragments.push(image); renderedFragments.push(image);
continue; continue;
@ -202,11 +297,11 @@ async function textToElementArray(
return renderedFragments; return renderedFragments;
} }
type TokenizedText = (string | EmojiToken)[]; type TokenizedText = (string | EmojiState)[];
export function tokenizeText(text: string): TokenizedText { export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) { if (!text.trim()) {
return []; return [text];
} }
const tokens = []; const tokens = [];
@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText {
// Custom emoji // Custom emoji
tokens.push({ tokens.push({
type: EMOJI_TYPE_CUSTOM, type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons code,
} satisfies CustomEmojiToken); } satisfies EmojiStateCustom);
} else { } else {
// Unicode emoji // Unicode emoji
tokens.push({ tokens.push({
type: EMOJI_TYPE_UNICODE, type: EMOJI_TYPE_UNICODE,
code: code, code: code,
} satisfies UnicodeEmojiToken); } satisfies EmojiStateUnicode);
} }
lastIndex = match.index + code.length; lastIndex = match.index + code.length;
} }
@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale); const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale); const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) { for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji }); cache.set(emoji.hexcode, {
} type: EMOJI_TYPE_UNICODE,
const notFoundEmojis = missingEmojis.filter((code) => data: emoji,
emojis.every((emoji) => emoji.hexcode !== code), code: emoji.hexcode,
); });
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.
} }
localeCacheMap.set(currentLocale, cache); localeCacheMap.set(currentLocale, cache);
} }
@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchCustomEmojisByShortcodes(missingEmojis); const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM); const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) { for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji }); cache.set(emoji.shortcode, {
} type: EMOJI_TYPE_CUSTOM,
const notFoundEmojis = missingEmojis.filter((code) => data: emoji,
emojis.every((emoji) => emoji.shortcode !== code), code: emoji.shortcode,
); });
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.
} }
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache); 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 (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji // If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly. // we can just append the text node directly.
@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
image.classList.add('emojione'); image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) { 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.alt = state.data.unicode;
image.title = state.data.label; image.title = state.data.label;
image.src = `${assetHost}/emoji/${fileName}.svg`; image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
} else { } else {
// Custom emoji // Custom emoji
const shortCode = `:${state.data.shortcode}:`; const shortCode = `:${state.data.shortcode}:`;

View File

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

View File

@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input); 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 { export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) { if (supportsRegExpSets()) {
return new RegExp( return new RegExp(
@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean {
// Constant as this is supported by all browsers. // Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i; 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) { export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input); 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 type { List as ImmutableList, RecordOf } from 'immutable';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; 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 PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses'; import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
).size; ).size;
return ( return (
<div <AnimateEmojiProvider
className='notification-group__embedded-status animate-parent' className='notification-group__embedded-status'
role='button' role='button'
tabIndex={-1} tabIndex={-1}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@ -148,6 +149,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
)} )}
</div> </div>
)} )}
</div> </AnimateEmojiProvider>
); );
}; };

View File

@ -2,28 +2,19 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; 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 ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.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 StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.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 'flavours/glitch/actions/compose'; import { replyCompose } from 'flavours/glitch/actions/compose';
import { import { toggleFavourite } from 'flavours/glitch/actions/interactions';
toggleReblog,
toggleFavourite,
} from 'flavours/glitch/actions/interactions';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { IconButton } from 'flavours/glitch/components/icon_button'; import { IconButton } from 'flavours/glitch/components/icon_button';
import { BoostButton } from 'flavours/glitch/components/status/boost_button';
import { useIdentity } from 'flavours/glitch/identity_context'; import { useIdentity } from 'flavours/glitch/identity_context';
import { me } from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account'; import type { Account } from 'flavours/glitch/models/account';
import type { Status } from 'flavours/glitch/models/status'; import type { Status } from 'flavours/glitch/models/status';
import { makeGetStatus } from 'flavours/glitch/selectors'; import { makeGetStatus } from 'flavours/glitch/selectors';
@ -130,29 +121,6 @@ export const Footer: React.FC<{
[dispatch, status, signedIn], [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( const handleOpenClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (e.button !== 0 || !status) { if (e.button !== 0 || !status) {
@ -170,13 +138,6 @@ export const Footer: React.FC<{
return null; 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; let replyIcon, replyIconComponent, replyTitle;
if (status.get('in_reply_to_id', null) === null) { if (status.get('in_reply_to_id', null) === null) {
@ -189,24 +150,6 @@ export const Footer: React.FC<{
replyTitle = intl.formatMessage(messages.replyAll); 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( const favouriteTitle = intl.formatMessage(
status.get('favourited') ? messages.removeFavourite : messages.favourite, status.get('favourited') ? messages.removeFavourite : messages.favourite,
); );
@ -235,19 +178,7 @@ export const Footer: React.FC<{
obfuscateCount={!showReplyCount} obfuscateCount={!showReplyCount}
/> />
<IconButton <BoostButton counters status={status} />
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)
}
/>
<IconButton <IconButton
className='status__action-bar-button star-icon' className='status__action-bar-button star-icon'

View File

@ -12,6 +12,8 @@ import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator'; import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
import StatusList from 'flavours/glitch/components/status_list'; import StatusList from 'flavours/glitch/components/status_list';
import { useIdentity } from 'flavours/glitch/identity_context';
import { domain } from 'flavours/glitch/initial_state';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
@ -31,9 +33,18 @@ export const Quotes: React.FC<{
const statusId = params?.statusId; const statusId = params?.statusId;
const { accountId: me } = useIdentity();
const isCorrectStatusId: boolean = useAppSelector( const isCorrectStatusId: boolean = useAppSelector(
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId, (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) => const statusIds = useAppSelector((state) =>
state.status_lists.getIn(['quotes', 'items'], emptyList), 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 ( return (
<Column bindToDocument={!multiColumn}> <Column bindToDocument={!multiColumn}>
<ColumnHeader <ColumnHeader
@ -100,6 +137,7 @@ export const Quotes: React.FC<{
isLoading={isLoading} isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
prepend={prependMessage}
/> />
<Helmet> <Helmet>

View File

@ -14,7 +14,7 @@ import { hydrateStore } from 'flavours/glitch/actions/store';
import { Router } from 'flavours/glitch/components/router'; import { Router } from 'flavours/glitch/components/router';
import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status'; import { DetailedStatus } from 'flavours/glitch/features/status/components/detailed_status';
import { useRenderSignal } from 'flavours/glitch/hooks/useRenderSignal'; import { useRenderSignal } from 'flavours/glitch/hooks/useRenderSignal';
import initialState from 'flavours/glitch/initial_state'; import { initialState } from 'flavours/glitch/initial_state';
import { IntlProvider } from 'flavours/glitch/locales'; import { IntlProvider } from 'flavours/glitch/locales';
import { import {
makeGetStatus, makeGetStatus,

View File

@ -32,7 +32,7 @@ import { VisibilityIcon } from 'flavours/glitch/components/visibility_icon';
import { Audio } from 'flavours/glitch/features/audio'; import { Audio } from 'flavours/glitch/features/audio';
import scheduleIdleTask from 'flavours/glitch/features/ui/util/schedule_idle_task'; import scheduleIdleTask from 'flavours/glitch/features/ui/util/schedule_idle_task';
import { Video } from 'flavours/glitch/features/video'; import { Video } from 'flavours/glitch/features/video';
import { me } from 'flavours/glitch/initial_state'; import { useIdentity } from 'flavours/glitch/identity_context';
import { useAppSelector } from 'flavours/glitch/store'; import { useAppSelector } from 'flavours/glitch/store';
import Card from './card'; import Card from './card';
@ -95,6 +95,8 @@ export const DetailedStatus: React.FC<{
state.local_settings.getIn(['media', 'fullwidth'], false) as boolean, state.local_settings.getIn(['media', 'fullwidth'], false) as boolean,
); );
const { signedIn } = useIdentity();
const handleOpenVideo = useCallback( const handleOpenVideo = useCallback(
(options: VideoModalOptions) => { (options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) || const lang = (status.getIn(['translation', 'language']) ||
@ -327,7 +329,7 @@ export const DetailedStatus: React.FC<{
if (['private', 'direct'].includes(status.get('visibility') as string)) { if (['private', 'direct'].includes(status.get('visibility') as string)) {
quotesLink = ''; quotesLink = '';
} else if (status.getIn(['account', 'id']) === me) { } else if (signedIn) {
quotesLink = ( quotesLink = (
<Link <Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`} to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}

View File

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

View File

@ -5,7 +5,9 @@ export {
ConfirmReplyModal, ConfirmReplyModal,
ConfirmEditStatusModal, ConfirmEditStatusModal,
} from './discard_draft_confirmation'; } from './discard_draft_confirmation';
export { ConfirmWithdrawRequestModal } from './withdraw_follow_request';
export { ConfirmUnfollowModal } from './unfollow'; export { ConfirmUnfollowModal } from './unfollow';
export { ConfirmUnblockModal } from './unblock';
export { ConfirmClearNotificationsModal } from './clear_notifications'; export { ConfirmClearNotificationsModal } from './clear_notifications';
export { ConfirmLogOutModal } from './log_out'; export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list'; 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 'flavours/glitch/actions/accounts';
import type { Account } from 'flavours/glitch/models/account';
import { useAppDispatch } from 'flavours/glitch/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'; import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({ const messages = defineMessages({
unfollowTitle: {
id: 'confirmations.unfollow.title',
defaultMessage: 'Unfollow user?',
},
unfollowConfirm: { unfollowConfirm: {
id: 'confirmations.unfollow.confirm', id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow', defaultMessage: 'Unfollow',
@ -34,12 +30,11 @@ export const ConfirmUnfollowModal: React.FC<
return ( return (
<ConfirmationModal <ConfirmationModal
title={intl.formatMessage(messages.unfollowTitle)} title={
message={
<FormattedMessage <FormattedMessage
id='confirmations.unfollow.message' id='confirmations.unfollow.title'
defaultMessage='Are you sure you want to unfollow {name}?' defaultMessage='Unfollow {name}?'
values={{ name: <strong>@{account.acct}</strong> }} values={{ name: `@${account.acct}` }}
/> />
} }
confirm={intl.formatMessage(messages.unfollowConfirm)} 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 'flavours/glitch/actions/accounts';
import type { Account } from 'flavours/glitch/models/account';
import { useAppDispatch } from 'flavours/glitch/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

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

View File

@ -30,7 +30,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; 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 BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar'; import { NavigationBar } from './components/navigation_bar';

View File

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

View File

@ -1,171 +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=} favourite_modal
* @property {boolean} crop_images
* @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} unfollow_modal
* @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
* @property {boolean} system_emoji_font
* @property {string} default_content_type
*/
/**
* @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 {object} local_settings
* @property {number} max_feed_hashtags
* @property {number} poll_limits
* @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');
// Glitch-soc-specific “local settings”
if (initialState) {
try {
// @ts-expect-error
initialState.local_settings = JSON.parse(localStorage.getItem('mastodon-settings'));
} catch {
initialState.local_settings = {};
}
}
/**
* @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 cropImages = getMeta('crop_images');
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]];
});
// Glitch-soc-specific settings
export const maxFeedHashtags = (initialState && initialState.max_feed_hashtags) || 4;
export const favouriteModal = getMeta('favourite_modal');
export const pollLimits = (initialState && initialState.poll_limits);
export const defaultContentType = getMeta('default_content_type');
export const useSystemEmojiFont = getMeta('system_emoji_font');
/**
* @returns {string | undefined}
*/
export function getAccessToken() {
return getMeta('access_token');
}
export default initialState;

View File

@ -0,0 +1,176 @@
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;
favourite_modal?: boolean;
crop_images: 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;
system_emoji_font?: boolean;
default_content_type: string;
}
interface Role {
id: string;
name: string;
permissions: string;
color: string;
highlighted: boolean;
}
interface PollLimits {
max_options: number;
max_option_chars: number;
min_expiration: number;
max_expiration: number;
}
export interface InitialState {
accounts: Record<string, ApiAccountJSON>;
languages: InitialStateLanguage[];
critical_updates_pending?: boolean;
meta: InitialStateMeta;
role?: Role;
features: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
local_settings: any;
max_feed_hashtags: number;
poll_limits: PollLimits;
}
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');
// Glitch-soc-specific “local settings”
if (initialState) {
try {
const initialStateString = localStorage.getItem('mastodon-settings');
if (initialStateString) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
initialState.local_settings = JSON.parse(initialStateString);
}
} catch {
initialState.local_settings = {};
}
}
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],
];
});
// Glitch-soc-specific settings
export const maxFeedHashtags = initialState?.max_feed_hashtags ?? 4;
export const favouriteModal = getMeta('favourite_modal');
export const pollLimits = initialState?.poll_limits;
export const defaultContentType = getMeta('default_content_type');
export const useSystemEmojiFont = getMeta('system_emoji_font');
export function getAccessToken(): string | undefined {
return getMeta('access_token');
}

View File

@ -13,6 +13,7 @@ $content-width: 840px;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
padding: env(safe-area-inset-top) env(safe-area-inset-right) padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left); env(safe-area-inset-bottom) env(safe-area-inset-left);
@ -25,6 +26,7 @@ $content-width: 840px;
.sidebar-wrapper { .sidebar-wrapper {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
flex: 1 1 auto; flex: 1 1 auto;

View File

@ -69,6 +69,7 @@ body {
&.layout-single-column { &.layout-single-column {
height: auto; height: auto;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
overflow-y: scroll; overflow-y: scroll;
} }
@ -181,7 +182,8 @@ button {
} }
& > noscript { & > noscript {
height: 100vh; min-height: 100vh;
min-height: 100dvh;
} }
} }
@ -189,6 +191,7 @@ button {
&, &,
& > div { & > div {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
} }
} }

View File

@ -2343,6 +2343,7 @@
position: relative; position: relative;
border-radius: var(--avatar-border-radius); border-radius: var(--avatar-border-radius);
background: var(--surface-background-color); background: var(--surface-background-color);
flex-shrink: 0;
img { img {
width: 100%; width: 100%;
@ -3053,6 +3054,7 @@ a.account__display-name {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
&__pane { &__pane {
@ -3385,6 +3387,7 @@ a.account__display-name {
.columns-area__panels { .columns-area__panels {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
gap: 0; gap: 0;
} }
@ -6639,8 +6642,11 @@ a.status-card {
line-height: 24px; line-height: 24px;
color: $primary-text-color; color: $primary-text-color;
font-weight: 500; font-weight: 500;
&:not(:only-child) {
margin-bottom: 8px; margin-bottom: 8px;
} }
}
strong { strong {
font-weight: 700; font-weight: 700;
@ -8710,47 +8716,6 @@ noscript {
overflow: hidden; overflow: hidden;
margin-inline-start: -2px; // aligns the pfp with content below margin-inline-start: -2px; // aligns the pfp with content below
&__buttons {
display: flex;
align-items: center;
gap: 8px;
padding-top: 55px;
overflow: hidden;
.button {
flex-shrink: 1;
white-space: nowrap;
min-width: 80px;
}
.icon-button {
border: 1px solid var(--background-border-color);
border-radius: 4px;
box-sizing: content-box;
padding: 5px;
.icon {
width: 24px;
height: 24px;
}
&.copied {
border-color: $valid-value-color;
}
}
.optional {
@container account-header (max-width: 372px) {
display: none;
}
// Fallback for older browsers with no container queries support
@media screen and (max-width: (372px + 55px)) {
display: none;
}
}
}
&__name { &__name {
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
@ -8799,6 +8764,69 @@ noscript {
} }
} }
&__follow-button {
flex-grow: 1;
}
&__buttons {
display: flex;
align-items: center;
gap: 8px;
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
&--desktop {
margin-top: 55px;
@container (width < #{$button-breakpoint}) {
display: none;
}
@supports (not (container-type: inline-size)) {
@media (max-width: #{$button-fallback-breakpoint}) {
display: none;
}
}
}
&--mobile {
margin-block: 16px;
@container (width >= #{$button-breakpoint}) {
display: none;
}
@supports (not (container-type: inline-size)) {
@media (min-width: (#{$button-fallback-breakpoint} + 1px)) {
display: none;
}
}
}
.button {
flex-shrink: 1;
white-space: nowrap;
min-width: 80px;
}
.icon-button {
border: 1px solid var(--background-border-color);
border-radius: 4px;
box-sizing: content-box;
padding: 5px;
.icon {
width: 24px;
height: 24px;
}
&.copied {
border-color: $valid-value-color;
}
}
}
&__bio { &__bio {
.account__header__content { .account__header__content {
color: $primary-text-color; color: $primary-text-color;
@ -11221,6 +11249,7 @@ noscript {
font-weight: 500; font-weight: 500;
color: $primary-text-color; color: $primary-text-color;
text-decoration: none; text-decoration: none;
min-width: 0;
&:hover, &:hover,
&:focus, &:focus,

View File

@ -1,4 +1,4 @@
import initialState from '../initial_state'; import { initialState } from '../initial_state';
export function isDevelopment() { export function isDevelopment() {
if (typeof process !== 'undefined') if (typeof process !== 'undefined')

View File

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

View File

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

View File

@ -1,8 +1,9 @@
import type { ComponentPropsWithoutRef, FC } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { EmojiHTML } from '../emoji/html';
import type { DisplayNameProps } from './index'; import type { DisplayNameProps } from './index';
export const DisplayNameSimple: FC< export const DisplayNameSimple: FC<
@ -12,12 +13,19 @@ export const DisplayNameSimple: FC<
if (!account) { if (!account) {
return null; return null;
} }
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
return ( return (
<bdi> <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> </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 { import {
fetchRelationships, fetchRelationships,
followAccount, followAccount,
unblockAccount,
unmuteAccount, unmuteAccount,
} from 'mastodon/actions/accounts'; } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
@ -59,7 +58,8 @@ export const FollowButton: React.FC<{
accountId?: string; accountId?: string;
compact?: boolean; compact?: boolean;
labelLength?: 'auto' | 'short' | 'long'; labelLength?: 'auto' | 'short' | 'long';
}> = ({ accountId, compact, labelLength = 'auto' }) => { className?: string;
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { signedIn } = useIdentity(); const { signedIn } = useIdentity();
@ -96,12 +96,24 @@ export const FollowButton: React.FC<{
return; return;
} else if (relationship.muting) { } else if (relationship.muting) {
dispatch(unmuteAccount(accountId)); dispatch(unmuteAccount(accountId));
} else if (account && (relationship.following || relationship.requested)) { } else if (account && relationship.following) {
dispatch( dispatch(
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
); );
} else if (account && relationship.requested) {
dispatch(
openModal({
modalType: 'CONFIRM_WITHDRAW_REQUEST',
modalProps: { account },
}),
);
} else if (relationship.blocking) { } else if (relationship.blocking) {
dispatch(unblockAccount(accountId)); dispatch(
openModal({
modalType: 'CONFIRM_UNBLOCK',
modalProps: { account },
}),
);
} else { } else {
dispatch(followAccount(accountId)); dispatch(followAccount(accountId));
} }
@ -144,7 +156,7 @@ export const FollowButton: React.FC<{
href='/settings/profile' href='/settings/profile'
target='_blank' target='_blank'
rel='noopener' rel='noopener'
className={classNames('button button-secondary', { className={classNames(className, 'button button-secondary', {
'button--compact': compact, 'button--compact': compact,
})} })}
> >
@ -158,13 +170,12 @@ export const FollowButton: React.FC<{
onClick={handleClick} onClick={handleClick}
disabled={ disabled={
relationship?.blocked_by || relationship?.blocked_by ||
relationship?.blocking ||
(!(relationship?.following || relationship?.requested) && (!(relationship?.following || relationship?.requested) &&
(account?.suspended || !!account?.moved)) (account?.suspended || !!account?.moved))
} }
secondary={following} secondary={following}
compact={compact} compact={compact}
className={following ? 'button--destructive' : undefined} className={classNames(className, { 'button--destructive': following })}
> >
{label} {label}
</Button> </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 { Icon } from 'mastodon/components/icon';
import { Poll } from 'mastodon/components/poll'; import { Poll } from 'mastodon/components/poll';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; import { languages as preloadedLanguages } from 'mastodon/initial_state';
import { EmojiHTML } from '../features/emoji/emoji_html';
import { isModernEmojiEnabled } from '../utils/environment'; import { isModernEmojiEnabled } from '../utils/environment';
import { EmojiHTML } from './emoji/html';
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) 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 { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router'; import { Router } from 'mastodon/components/router';
import Compose from 'mastodon/features/standalone/compose'; 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 { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store'; 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 { Router } from 'mastodon/components/router';
import UI from 'mastodon/features/ui'; import UI from 'mastodon/features/ui';
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context'; 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 { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store'; import { store } from 'mastodon/store';
import { isProduction } from 'mastodon/utils/environment'; 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 { AccountBio } from '@/mastodon/components/account_bio';
import { DisplayName } from '@/mastodon/components/display_name'; 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 CheckIcon from '@/material-icons/400-24px/check.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.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 { initReport } from 'mastodon/actions/reports';
import { Avatar } from 'mastodon/components/avatar'; import { Avatar } from 'mastodon/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge'; import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
import { Button } from 'mastodon/components/button';
import { CopyIconButton } from 'mastodon/components/copy_icon_button'; import { CopyIconButton } from 'mastodon/components/copy_icon_button';
import { import {
FollowersCounter, FollowersCounter,
@ -383,7 +383,7 @@ export const AccountHeader: React.FC<{
const isRemote = account?.acct !== account?.username; const isRemote = account?.acct !== account?.username;
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
const menu = useMemo(() => { const menuItems = useMemo(() => {
const arr: MenuItem[] = []; const arr: MenuItem[] = [];
if (!account) { if (!account) {
@ -605,6 +605,15 @@ export const AccountHeader: React.FC<{
handleUnblockDomain, handleUnblockDomain,
]); ]);
const menu = accountId !== me && (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
);
if (!account) { if (!account) {
return null; return null;
} }
@ -718,21 +727,16 @@ export const AccountHeader: React.FC<{
); );
} }
if (relationship?.blocking) { const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
if (!isMovedAndUnfollowedAccount) {
actionBtn = ( actionBtn = (
<Button <FollowButton
text={intl.formatMessage(messages.unblock, { accountId={accountId}
name: account.username, className='account__header__follow-button'
})} labelLength='long'
onClick={handleBlock}
/> />
); );
} else {
actionBtn = <FollowButton accountId={accountId} />;
}
if (account.moved && !relationship?.following) {
actionBtn = '';
} }
if (account.locked) { if (account.locked) {
@ -777,8 +781,8 @@ export const AccountHeader: React.FC<{
<MovedNote accountId={account.id} targetAccountId={account.moved} /> <MovedNote accountId={account.id} targetAccountId={account.moved} />
)} )}
<div <AnimateEmojiProvider
className={classNames('account__header animate-parent', { className={classNames('account__header', {
inactive: !!account.moved, inactive: !!account.moved,
})} })}
> >
@ -814,18 +818,11 @@ export const AccountHeader: React.FC<{
/> />
</a> </a>
<div className='account__header__tabs__buttons'> <div className='account__header__buttons account__header__buttons--desktop'>
{!hidden && actionBtn}
{!hidden && bellBtn} {!hidden && bellBtn}
{!hidden && shareBtn} {!hidden && shareBtn}
{accountId !== me && ( {menu}
<Dropdown
disabled={menu.length === 0}
items={menu}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
/>
)}
{!hidden && actionBtn}
</div> </div>
</div> </div>
@ -855,6 +852,12 @@ export const AccountHeader: React.FC<{
<FamiliarFollowers accountId={accountId} /> <FamiliarFollowers accountId={accountId} />
)} )}
<div className='account__header__buttons account__header__buttons--mobile'>
{!hidden && actionBtn}
{!hidden && bellBtn}
{menu}
</div>
{!(suspended || hidden) && ( {!(suspended || hidden) && (
<div className='account__header__extra'> <div className='account__header__extra'>
<div <div
@ -967,7 +970,7 @@ export const AccountHeader: React.FC<{
</div> </div>
)} )}
</div> </div>
</div> </AnimateEmojiProvider>
{!(hideTabs || hidden) && ( {!(hideTabs || hidden) && (
<div className='account__section-headline'> <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 { Dropdown } from 'mastodon/components/dropdown_menu';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
import { LinkedDisplayName } from '@/mastodon/components/display_name'; import { LinkedDisplayName } from '@/mastodon/components/display_name';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
const messages = defineMessages({ const messages = defineMessages({
more: { id: 'status.more', defaultMessage: 'More' }, 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')} /> {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
</div> </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> }} /> <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
</div> </AnimateEmojiProvider>
</div> </div>
<StatusContent <StatusContent

View File

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

View File

@ -197,11 +197,18 @@ function toLoadedLocale(localeString: string) {
log(`Locale ${locale} is different from provided ${localeString}`); log(`Locale ${locale} is different from provided ${localeString}`);
} }
if (!loadedLocales.has(locale)) { if (!loadedLocales.has(locale)) {
throw new Error(`Locale ${locale} is not loaded in emoji database`); throw new LocaleNotLoadedError(locale);
} }
return 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> { async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
if (loadedLocales.has(locale)) { if (loadedLocales.has(locale)) {
return true; 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 { loadWorker } from '@/mastodon/utils/workers';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';

View File

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

View File

@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase';
import unicodeRawEmojis from 'emojibase-data/en/data.json'; import unicodeRawEmojis from 'emojibase-data/en/data.json';
import { import {
twemojiHasBorder,
twemojiToUnicodeInfo, twemojiToUnicodeInfo,
unicodeToTwemojiHex, unicodeToTwemojiHex,
CODES_WITH_DARK_BORDER,
CODES_WITH_LIGHT_BORDER,
emojiToUnicodeHex, emojiToUnicodeHex,
} from './normalize'; } 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', () => { describe('twemojiToUnicodeInfo', () => {
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode)); const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));

View File

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

View File

@ -1,10 +1,6 @@
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories'; import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
import { import { EMOJI_MODE_TWEMOJI } from './constants';
EMOJI_MODE_NATIVE,
EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_MODE_TWEMOJI,
} from './constants';
import * as db from './database'; import * as db from './database';
import { import {
emojifyElement, emojifyElement,
@ -12,7 +8,7 @@ import {
testCacheClear, testCacheClear,
tokenizeText, tokenizeText,
} from './render'; } from './render';
import type { EmojiAppState, ExtraCustomEmojiMap } from './types'; import type { EmojiAppState } from './types';
function mockDatabase() { function mockDatabase() {
return { return {
@ -40,18 +36,6 @@ const expectedSmileImage =
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">'; '<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
const expectedFlagImage = const expectedFlagImage =
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">'; '<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> = {}) { function testAppState(state: Partial<EmojiAppState> = {}) {
return { return {
@ -86,64 +70,10 @@ describe('emojifyElement', () => {
'en', 'en',
); );
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([ 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 () => { test('returns null when no emoji are found', async () => {
mockDatabase(); mockDatabase();
const actual = await emojifyElement( const actual = await emojifyElement(
@ -165,28 +95,9 @@ describe('emojifyText', () => {
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState()); const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`); 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', () => { 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', () => { test('returns an array of text to be a single token', () => {
expect(tokenizeText('Hello')).toEqual(['Hello']); expect(tokenizeText('Hello')).toEqual(['Hello']);
}); });
@ -212,7 +123,7 @@ describe('tokenizeText', () => {
'Hello ', 'Hello ',
{ {
type: 'custom', type: 'custom',
code: 'smile', code: ':smile:',
}, },
'!!', '!!',
]); ]);
@ -223,7 +134,7 @@ describe('tokenizeText', () => {
'Hello ', 'Hello ',
{ {
type: 'custom', type: 'custom',
code: 'smile_123', code: ':smile_123:',
}, },
'!!', '!!',
]); ]);
@ -239,7 +150,7 @@ describe('tokenizeText', () => {
' ', ' ',
{ {
type: 'custom', type: 'custom',
code: 'smile', code: ':smile:',
}, },
'!!', '!!',
]); ]);

View File

@ -1,6 +1,5 @@
import { autoPlayGif } from '@/mastodon/initial_state'; import { autoPlayGif } from '@/mastodon/initial_state';
import { createLimitedCache } from '@/mastodon/utils/cache'; import { createLimitedCache } from '@/mastodon/utils/cache';
import { assetHost } from '@/mastodon/utils/config';
import * as perf from '@/mastodon/utils/performance'; import * as perf from '@/mastodon/utils/performance';
import { import {
@ -8,38 +7,130 @@ import {
EMOJI_MODE_NATIVE_WITH_FLAGS, EMOJI_MODE_NATIVE_WITH_FLAGS,
EMOJI_TYPE_UNICODE, EMOJI_TYPE_UNICODE,
EMOJI_TYPE_CUSTOM, EMOJI_TYPE_CUSTOM,
EMOJI_STATE_MISSING,
} from './constants'; } from './constants';
import { import {
loadCustomEmojiByShortcode,
loadEmojiByHexcode,
LocaleNotLoadedError,
searchCustomEmojisByShortcodes, searchCustomEmojisByShortcodes,
searchEmojisByHexcodes, searchEmojisByHexcodes,
} from './database'; } from './database';
import { import { importEmojiData } from './loader';
emojiToUnicodeHex, import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
twemojiHasBorder,
unicodeToTwemojiHex,
} from './normalize';
import type { import type {
CustomEmojiToken,
EmojiAppState, EmojiAppState,
EmojiLoadedState, EmojiLoadedState,
EmojiMode, EmojiMode,
EmojiState, EmojiState,
EmojiStateCustom,
EmojiStateMap, EmojiStateMap,
EmojiToken, EmojiStateUnicode,
ExtraCustomEmojiMap, ExtraCustomEmojiMap,
LocaleOrCustom, LocaleOrCustom,
UnicodeEmojiToken,
} from './types'; } from './types';
import { import {
anyEmojiRegex, anyEmojiRegex,
emojiLogger, emojiLogger,
isCustomEmoji,
isUnicodeEmoji,
stringHasAnyEmoji, stringHasAnyEmoji,
stringHasUnicodeFlags, stringHasUnicodeFlags,
} from './utils'; } from './utils';
const log = emojiLogger('render'); 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. * 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) { if (token.type === EMOJI_TYPE_CUSTOM) {
const extraEmojiData = extraEmojis[token.code]; const extraEmojiData = extraEmojis[token.code];
if (extraEmojiData) { if (extraEmojiData) {
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData }; state = {
type: EMOJI_TYPE_CUSTOM,
data: extraEmojiData,
code: token.code,
};
} else { } else {
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM); 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 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); const image = stateToImage(state, appState);
renderedFragments.push(image); renderedFragments.push(image);
continue; continue;
@ -202,11 +297,11 @@ async function textToElementArray(
return renderedFragments; return renderedFragments;
} }
type TokenizedText = (string | EmojiToken)[]; type TokenizedText = (string | EmojiState)[];
export function tokenizeText(text: string): TokenizedText { export function tokenizeText(text: string): TokenizedText {
if (!text.trim()) { if (!text.trim()) {
return []; return [text];
} }
const tokens = []; const tokens = [];
@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText {
// Custom emoji // Custom emoji
tokens.push({ tokens.push({
type: EMOJI_TYPE_CUSTOM, type: EMOJI_TYPE_CUSTOM,
code: code.slice(1, -1), // Remove the colons code,
} satisfies CustomEmojiToken); } satisfies EmojiStateCustom);
} else { } else {
// Unicode emoji // Unicode emoji
tokens.push({ tokens.push({
type: EMOJI_TYPE_UNICODE, type: EMOJI_TYPE_UNICODE,
code: code, code: code,
} satisfies UnicodeEmojiToken); } satisfies EmojiStateUnicode);
} }
lastIndex = match.index + code.length; lastIndex = match.index + code.length;
} }
@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale); const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
const cache = cacheForLocale(currentLocale); const cache = cacheForLocale(currentLocale);
for (const emoji of emojis) { for (const emoji of emojis) {
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji }); cache.set(emoji.hexcode, {
} type: EMOJI_TYPE_UNICODE,
const notFoundEmojis = missingEmojis.filter((code) => data: emoji,
emojis.every((emoji) => emoji.hexcode !== code), code: emoji.hexcode,
); });
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.
} }
localeCacheMap.set(currentLocale, cache); localeCacheMap.set(currentLocale, cache);
} }
@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache(
const emojis = await searchCustomEmojisByShortcodes(missingEmojis); const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM); const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
for (const emoji of emojis) { for (const emoji of emojis) {
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji }); cache.set(emoji.shortcode, {
} type: EMOJI_TYPE_CUSTOM,
const notFoundEmojis = missingEmojis.filter((code) => data: emoji,
emojis.every((emoji) => emoji.shortcode !== code), code: emoji.shortcode,
); });
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.
} }
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache); 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 (token.type === EMOJI_TYPE_UNICODE) {
// If the mode is native or native with flags for non-flag emoji // If the mode is native or native with flags for non-flag emoji
// we can just append the text node directly. // we can just append the text node directly.
@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
image.classList.add('emojione'); image.classList.add('emojione');
if (state.type === EMOJI_TYPE_UNICODE) { 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.alt = state.data.unicode;
image.title = state.data.label; image.title = state.data.label;
image.src = `${assetHost}/emoji/${fileName}.svg`; image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
} else { } else {
// Custom emoji // Custom emoji
const shortCode = `:${state.data.shortcode}:`; const shortCode = `:${state.data.shortcode}:`;

View File

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

View File

@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean {
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input); 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 { export function stringHasUnicodeFlags(input: string): boolean {
if (supportsRegExpSets()) { if (supportsRegExpSets()) {
return new RegExp( return new RegExp(
@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean {
// Constant as this is supported by all browsers. // Constant as this is supported by all browsers.
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i; 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) { export function stringHasCustomEmoji(input: string) {
return CUSTOM_EMOJI_REGEX.test(input); 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 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 BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'mastodon/actions/statuses'; import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
).size; ).size;
return ( return (
<div <AnimateEmojiProvider
className='notification-group__embedded-status animate-parent' className='notification-group__embedded-status'
role='button' role='button'
tabIndex={-1} tabIndex={-1}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
@ -148,6 +149,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
)} )}
</div> </div>
)} )}
</div> </AnimateEmojiProvider>
); );
}; };

View File

@ -2,25 +2,19 @@ import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; 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 ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.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 StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import StarBorderIcon from '@/material-icons/400-24px/star.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 { 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 { openModal } from 'mastodon/actions/modal';
import { IconButton } from 'mastodon/components/icon_button'; import { IconButton } from 'mastodon/components/icon_button';
import { BoostButton } from 'mastodon/components/status/boost_button';
import { useIdentity } from 'mastodon/identity_context'; import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account'; import type { Account } from 'mastodon/models/account';
import type { Status } from 'mastodon/models/status'; import type { Status } from 'mastodon/models/status';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
@ -120,29 +114,6 @@ export const Footer: React.FC<{
} }
}, [dispatch, status, signedIn]); }, [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( const handleOpenClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (e.button !== 0 || !status) { if (e.button !== 0 || !status) {
@ -160,13 +131,6 @@ export const Footer: React.FC<{
return null; 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; let replyIcon, replyIconComponent, replyTitle;
if (status.get('in_reply_to_id', null) === null) { if (status.get('in_reply_to_id', null) === null) {
@ -179,24 +143,6 @@ export const Footer: React.FC<{
replyTitle = intl.formatMessage(messages.replyAll); 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( const favouriteTitle = intl.formatMessage(
status.get('favourited') ? messages.removeFavourite : messages.favourite, status.get('favourited') ? messages.removeFavourite : messages.favourite,
); );
@ -222,19 +168,7 @@ export const Footer: React.FC<{
counter={status.get('replies_count') as number} counter={status.get('replies_count') as number}
/> />
<IconButton <BoostButton counters status={status} />
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)
}
/>
<IconButton <IconButton
className='status__action-bar-button star-icon' 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 { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import StatusList from 'mastodon/components/status_list'; 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 { useAppDispatch, useAppSelector } from 'mastodon/store';
import Column from '../ui/components/column'; import Column from '../ui/components/column';
@ -31,9 +33,18 @@ export const Quotes: React.FC<{
const statusId = params?.statusId; const statusId = params?.statusId;
const { accountId: me } = useIdentity();
const isCorrectStatusId: boolean = useAppSelector( const isCorrectStatusId: boolean = useAppSelector(
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId, (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) => const statusIds = useAppSelector((state) =>
state.status_lists.getIn(['quotes', 'items'], emptyList), 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 ( return (
<Column bindToDocument={!multiColumn}> <Column bindToDocument={!multiColumn}>
<ColumnHeader <ColumnHeader
@ -100,6 +137,7 @@ export const Quotes: React.FC<{
isLoading={isLoading} isLoading={isLoading}
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
prepend={prependMessage}
/> />
<Helmet> <Helmet>

View File

@ -11,7 +11,7 @@ import { hydrateStore } from 'mastodon/actions/store';
import { Router } from 'mastodon/components/router'; import { Router } from 'mastodon/components/router';
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status'; import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal'; import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
import initialState from 'mastodon/initial_state'; import { initialState } from 'mastodon/initial_state';
import { IntlProvider } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors'; import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
import { store, useAppSelector, useAppDispatch } from 'mastodon/store'; 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 { Audio } from 'mastodon/features/audio';
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task'; import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
import { Video } from 'mastodon/features/video'; import { Video } from 'mastodon/features/video';
import { me } from 'mastodon/initial_state'; import { useIdentity } from 'mastodon/identity_context';
import Card from './card'; import Card from './card';
@ -75,6 +75,8 @@ export const DetailedStatus: React.FC<{
const [showDespiteFilter, setShowDespiteFilter] = useState(false); const [showDespiteFilter, setShowDespiteFilter] = useState(false);
const nodeRef = useRef<HTMLDivElement>(); const nodeRef = useRef<HTMLDivElement>();
const { signedIn } = useIdentity();
const handleOpenVideo = useCallback( const handleOpenVideo = useCallback(
(options: VideoModalOptions) => { (options: VideoModalOptions) => {
const lang = (status.getIn(['translation', 'language']) || const lang = (status.getIn(['translation', 'language']) ||
@ -283,7 +285,7 @@ export const DetailedStatus: React.FC<{
if (['private', 'direct'].includes(status.get('visibility') as string)) { if (['private', 'direct'].includes(status.get('visibility') as string)) {
quotesLink = ''; quotesLink = '';
} else if (status.getIn(['account', 'id']) === me) { } else if (signedIn) {
quotesLink = ( quotesLink = (
<Link <Link
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`} to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}

View File

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

View File

@ -5,7 +5,9 @@ export {
ConfirmReplyModal, ConfirmReplyModal,
ConfirmEditStatusModal, ConfirmEditStatusModal,
} from './discard_draft_confirmation'; } from './discard_draft_confirmation';
export { ConfirmWithdrawRequestModal } from './withdraw_follow_request';
export { ConfirmUnfollowModal } from './unfollow'; export { ConfirmUnfollowModal } from './unfollow';
export { ConfirmUnblockModal } from './unblock';
export { ConfirmClearNotificationsModal } from './clear_notifications'; export { ConfirmClearNotificationsModal } from './clear_notifications';
export { ConfirmLogOutModal } from './log_out'; export { ConfirmLogOutModal } from './log_out';
export { ConfirmFollowToListModal } from './follow_to_list'; 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'; import { ConfirmationModal } from './confirmation_modal';
const messages = defineMessages({ const messages = defineMessages({
unfollowTitle: {
id: 'confirmations.unfollow.title',
defaultMessage: 'Unfollow user?',
},
unfollowConfirm: { unfollowConfirm: {
id: 'confirmations.unfollow.confirm', id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow', defaultMessage: 'Unfollow',
@ -34,12 +30,11 @@ export const ConfirmUnfollowModal: React.FC<
return ( return (
<ConfirmationModal <ConfirmationModal
title={intl.formatMessage(messages.unfollowTitle)} title={
message={
<FormattedMessage <FormattedMessage
id='confirmations.unfollow.message' id='confirmations.unfollow.title'
defaultMessage='Are you sure you want to unfollow {name}?' defaultMessage='Unfollow {name}?'
values={{ name: <strong>@{account.acct}</strong> }} values={{ name: `@${account.acct}` }}
/> />
} }
confirm={intl.formatMessage(messages.unfollowConfirm)} 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, ConfirmDeleteListModal,
ConfirmReplyModal, ConfirmReplyModal,
ConfirmEditStatusModal, ConfirmEditStatusModal,
ConfirmUnblockModal,
ConfirmUnfollowModal, ConfirmUnfollowModal,
ConfirmWithdrawRequestModal,
ConfirmClearNotificationsModal, ConfirmClearNotificationsModal,
ConfirmLogOutModal, ConfirmLogOutModal,
ConfirmFollowToListModal, ConfirmFollowToListModal,
@ -57,7 +59,9 @@ export const MODAL_COMPONENTS = {
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }), 'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }), 'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }), 'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
'CONFIRM_UNFOLLOW': () => Promise.resolve({ default: ConfirmUnfollowModal }), 'CONFIRM_UNFOLLOW': () => Promise.resolve({ default: ConfirmUnfollowModal }),
'CONFIRM_WITHDRAW_REQUEST': () => Promise.resolve({ default: ConfirmWithdrawRequestModal }),
'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }), 'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }),
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }), 'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }), '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 { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; 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 BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar'; 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 { interface FocusColumnOptions {
index?: number; 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.mute.confirm": "Silenciar",
"confirmations.redraft.confirm": "Borrar y tornar ta borrador", "confirmations.redraft.confirm": "Borrar y tornar ta borrador",
"confirmations.unfollow.confirm": "Deixar de seguir", "confirmations.unfollow.confirm": "Deixar de seguir",
"confirmations.unfollow.message": "Yes seguro que quiers deixar de seguir a {name}?",
"conversation.delete": "Borrar conversación", "conversation.delete": "Borrar conversación",
"conversation.mark_as_read": "Marcar como leyiu", "conversation.mark_as_read": "Marcar como leyiu",
"conversation.open": "Veyer conversación", "conversation.open": "Veyer conversación",

View File

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

View File

@ -155,8 +155,6 @@
"confirmations.redraft.confirm": "Desaniciar y reeditar", "confirmations.redraft.confirm": "Desaniciar y reeditar",
"confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?", "confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?",
"confirmations.unfollow.confirm": "Dexar de siguir", "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.hide": "Esconder la publicación",
"content_warning.show": "Amosar de toes toes", "content_warning.show": "Amosar de toes toes",
"content_warning.show_more": "Amosar más", "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.message": "Bu əməliyyatın geri dönüşü yoxdur.",
"confirmations.revoke_quote.title": "Göndəriş silinsin?", "confirmations.revoke_quote.title": "Göndəriş silinsin?",
"confirmations.unfollow.confirm": "İzləmədən çıxar", "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.hide": "Paylaşımı gizlət",
"content_warning.show": "Yenə də göstər", "content_warning.show": "Yenə də göstər",
"content_warning.show_more": "Daha çox 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.message": "Гэтае дзеянне немагчыма адмяніць.",
"confirmations.revoke_quote.title": "Выдаліць допіс?", "confirmations.revoke_quote.title": "Выдаліць допіс?",
"confirmations.unfollow.confirm": "Адпісацца", "confirmations.unfollow.confirm": "Адпісацца",
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
"content_warning.hide": "Схаваць допіс", "content_warning.hide": "Схаваць допіс",
"content_warning.show": "Усё адно паказаць", "content_warning.show": "Усё адно паказаць",
"content_warning.show_more": "Паказаць усё роўна", "content_warning.show_more": "Паказаць усё роўна",

View File

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

View File

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

View File

@ -217,8 +217,6 @@
"confirmations.revoke_quote.confirm": "Dilemel an embannadur", "confirmations.revoke_quote.confirm": "Dilemel an embannadur",
"confirmations.revoke_quote.title": "Dilemel an embannadur?", "confirmations.revoke_quote.title": "Dilemel an embannadur?",
"confirmations.unfollow.confirm": "Diheuliañ", "confirmations.unfollow.confirm": "Diheuliañ",
"confirmations.unfollow.message": "Ha sur oc'h e fell deoc'h paouez da heuliañ {name} ?",
"confirmations.unfollow.title": "Paouez da heuliañ an implijer·ez?",
"content_warning.hide": "Kuzhat an embannadur", "content_warning.hide": "Kuzhat an embannadur",
"content_warning.show": "Diskwel memes tra", "content_warning.show": "Diskwel memes tra",
"content_warning.show_more": "Diskouez muioc'h", "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.message": "Aquesta acció no es pot desfer.",
"confirmations.revoke_quote.title": "Eliminar la publicació?", "confirmations.revoke_quote.title": "Eliminar la publicació?",
"confirmations.unfollow.confirm": "Deixa de seguir", "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.hide": "Amaga la publicació",
"content_warning.show": "Mostra-la igualment", "content_warning.show": "Mostra-la igualment",
"content_warning.show_more": "Mostra'n més", "content_warning.show_more": "Mostra'n més",

View File

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

View File

@ -87,7 +87,6 @@
"confirmations.mute.confirm": "Piattà", "confirmations.mute.confirm": "Piattà",
"confirmations.redraft.confirm": "Sguassà è riscrive", "confirmations.redraft.confirm": "Sguassà è riscrive",
"confirmations.unfollow.confirm": "Disabbunassi", "confirmations.unfollow.confirm": "Disabbunassi",
"confirmations.unfollow.message": "Site sicuru·a ch'ùn vulete più siguità @{name}?",
"conversation.delete": "Sguassà a cunversazione", "conversation.delete": "Sguassà a cunversazione",
"conversation.mark_as_read": "Marcà cum'è lettu", "conversation.mark_as_read": "Marcà cum'è lettu",
"conversation.open": "Vede a cunversazione", "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.message": "Tuto akci nelze vrátit zpět.",
"confirmations.revoke_quote.title": "Odstranit příspěvek?", "confirmations.revoke_quote.title": "Odstranit příspěvek?",
"confirmations.unfollow.confirm": "Přestat sledovat", "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.hide": "Skrýt příspěvek",
"content_warning.show": "Přesto zobrazit", "content_warning.show": "Přesto zobrazit",
"content_warning.show_more": "Zobrazit více", "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.message": "Does dim modd dadwneud y weithred hon.",
"confirmations.revoke_quote.title": "Dileu'r postiad?", "confirmations.revoke_quote.title": "Dileu'r postiad?",
"confirmations.unfollow.confirm": "Dad-ddilyn", "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.hide": "Cuddio'r postiad",
"content_warning.show": "Dangos beth bynnag", "content_warning.show": "Dangos beth bynnag",
"content_warning.show_more": "Dangos rhagor", "content_warning.show_more": "Dangos rhagor",

View File

@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Fjern indlæg", "confirmations.revoke_quote.confirm": "Fjern indlæg",
"confirmations.revoke_quote.message": "Denne handling kan ikke fortrydes.", "confirmations.revoke_quote.message": "Denne handling kan ikke fortrydes.",
"confirmations.revoke_quote.title": "Fjern indlæg?", "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.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 {name}?",
"confirmations.unfollow.title": "Følg ikke længere bruger?", "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.hide": "Skjul indlæg",
"content_warning.show": "Vis alligevel", "content_warning.show": "Vis alligevel",
"content_warning.show_more": "Vis flere", "content_warning.show_more": "Vis flere",
@ -920,6 +923,8 @@
"status.quote_private": "Private indlæg kan ikke citeres", "status.quote_private": "Private indlæg kan ikke citeres",
"status.quotes": "{count, plural, one {citat} other {citater}}", "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.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.read_more": "Læs mere",
"status.reblog": "Fremhæv", "status.reblog": "Fremhæv",
"status.reblog_or_quote": "Fremhæv eller citér", "status.reblog_or_quote": "Fremhæv eller citér",

View File

@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Beitrag entfernen", "confirmations.revoke_quote.confirm": "Beitrag entfernen",
"confirmations.revoke_quote.message": "Diese Aktion kann nicht rückgängig gemacht werden.", "confirmations.revoke_quote.message": "Diese Aktion kann nicht rückgängig gemacht werden.",
"confirmations.revoke_quote.title": "Beitrag entfernen?", "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.confirm": "Entfolgen",
"confirmations.unfollow.message": "Möchtest du {name} wirklich entfolgen?", "confirmations.unfollow.title": "{name} entfolgen?",
"confirmations.unfollow.title": "Profil 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.hide": "Beitrag ausblenden",
"content_warning.show": "Trotzdem anzeigen", "content_warning.show": "Trotzdem anzeigen",
"content_warning.show_more": "Beitrag anzeigen", "content_warning.show_more": "Beitrag anzeigen",
@ -920,6 +923,8 @@
"status.quote_private": "Private Beiträge können nicht zitiert werden", "status.quote_private": "Private Beiträge können nicht zitiert werden",
"status.quotes": "{count, plural, one {Mal zitiert} other {Mal zitiert}}", "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.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.read_more": "Gesamten Beitrag anschauen",
"status.reblog": "Teilen", "status.reblog": "Teilen",
"status.reblog_or_quote": "Teilen oder zitieren", "status.reblog_or_quote": "Teilen oder zitieren",

View File

@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Αφαίρεση ανάρτησης", "confirmations.revoke_quote.confirm": "Αφαίρεση ανάρτησης",
"confirmations.revoke_quote.message": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", "confirmations.revoke_quote.message": "Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.",
"confirmations.revoke_quote.title": "Αφαίρεση ανάρτησης;", "confirmations.revoke_quote.title": "Αφαίρεση ανάρτησης;",
"confirmations.unblock.confirm": "Άρση αποκλεισμού",
"confirmations.unblock.title": "Άρση αποκλεισμού {name};",
"confirmations.unfollow.confirm": "Άρση ακολούθησης", "confirmations.unfollow.confirm": "Άρση ακολούθησης",
"confirmations.unfollow.message": "Σίγουρα θες να πάψεις να ακολουθείς τον/την {name};", "confirmations.unfollow.title": "Κατάργηση ακολούθησης του/της {name};",
"confirmations.unfollow.title": "Άρση ακολούθησης;", "confirmations.withdraw_request.confirm": "Απόσυρση αιτήματος",
"confirmations.withdraw_request.title": "Απόσυρση αιτήματος για να ακολουθήσετε τον/την {name};",
"content_warning.hide": "Απόκρυψη ανάρτησης", "content_warning.hide": "Απόκρυψη ανάρτησης",
"content_warning.show": "Εμφάνιση ούτως ή άλλως", "content_warning.show": "Εμφάνιση ούτως ή άλλως",
"content_warning.show_more": "Εμφάνιση περισσότερων", "content_warning.show_more": "Εμφάνιση περισσότερων",
@ -920,6 +923,8 @@
"status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν", "status.quote_private": "Ιδιωτικές αναρτήσεις δεν μπορούν να παρατεθούν",
"status.quotes": "{count, plural, one {# παράθεση} other {# παραθέσεις}}", "status.quotes": "{count, plural, one {# παράθεση} other {# παραθέσεις}}",
"status.quotes.empty": "Κανείς δεν έχει παραθέσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει, θα εμφανιστεί εδώ.", "status.quotes.empty": "Κανείς δεν έχει παραθέσει αυτή την ανάρτηση ακόμα. Μόλις το κάνει, θα εμφανιστεί εδώ.",
"status.quotes.local_other_disclaimer": "Οι παραθέσεις που απορρίφθηκαν από τον συντάκτη δε θα εμφανιστούν.",
"status.quotes.remote_other_disclaimer": "Μόνο παραθέσεις από το {domain} είναι εγγυημένες ότι θα εμφανιστούν εδώ. Παραθέσεις που απορρίφθηκαν από τον συντάκτη δε θα εμφανιστούν.",
"status.read_more": "Διάβασε περισότερα", "status.read_more": "Διάβασε περισότερα",
"status.reblog": "Ενίσχυση", "status.reblog": "Ενίσχυση",
"status.reblog_or_quote": "Ενίσχυση ή παράθεση", "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.message": "{name} will stop following you. Are you sure you want to proceed?",
"confirmations.remove_from_followers.title": "Remove follower?", "confirmations.remove_from_followers.title": "Remove follower?",
"confirmations.unfollow.confirm": "Unfollow", "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.hide": "Hide post",
"content_warning.show": "Show anyway", "content_warning.show": "Show anyway",
"content_warning.show_more": "Show more", "content_warning.show_more": "Show more",

View File

@ -257,9 +257,12 @@
"confirmations.revoke_quote.confirm": "Remove post", "confirmations.revoke_quote.confirm": "Remove post",
"confirmations.revoke_quote.message": "This action cannot be undone.", "confirmations.revoke_quote.message": "This action cannot be undone.",
"confirmations.revoke_quote.title": "Remove post?", "confirmations.revoke_quote.title": "Remove post?",
"confirmations.unblock.confirm": "Unblock",
"confirmations.unblock.title": "Unblock {name}?",
"confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", "confirmations.unfollow.title": "Unfollow {name}?",
"confirmations.unfollow.title": "Unfollow user?", "confirmations.withdraw_request.confirm": "Withdraw request",
"confirmations.withdraw_request.title": "Withdraw request to follow {name}?",
"content_warning.hide": "Hide post", "content_warning.hide": "Hide post",
"content_warning.show": "Show anyway", "content_warning.show": "Show anyway",
"content_warning.show_more": "Show more", "content_warning.show_more": "Show more",
@ -920,6 +923,8 @@
"status.quote_private": "Private posts cannot be quoted", "status.quote_private": "Private posts cannot be quoted",
"status.quotes": "{count, plural, one {quote} other {quotes}}", "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.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.read_more": "Read more",
"status.reblog": "Boost", "status.reblog": "Boost",
"status.reblog_or_quote": "Boost or quote", "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.message": "Ĉi tiu ago ne povas esti malfarita.",
"confirmations.revoke_quote.title": "Ĉu forigi afiŝon?", "confirmations.revoke_quote.title": "Ĉu forigi afiŝon?",
"confirmations.unfollow.confirm": "Ne plu sekvi", "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.hide": "Kaŝi afiŝon",
"content_warning.show": "Montri ĉiukaze", "content_warning.show": "Montri ĉiukaze",
"content_warning.show_more": "Montri pli", "content_warning.show_more": "Montri pli",

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