diff --git a/app/controllers/api/v1/statuses/quotes_controller.rb b/app/controllers/api/v1/statuses/quotes_controller.rb index 962855884e..be3a4edc83 100644 --- a/app/controllers/api/v1/statuses/quotes_controller.rb +++ b/app/controllers/api/v1/statuses/quotes_controller.rb @@ -4,13 +4,13 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke - before_action :check_owner! + before_action :set_statuses, only: :index + before_action :set_quote, only: :revoke after_action :insert_pagination_headers, only: :index def index cache_if_unauthenticated! - @statuses = load_statuses render json: @statuses, each_serializer: REST::StatusSerializer end @@ -24,18 +24,26 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController private - def check_owner! - authorize @status, :list_quotes? - end - def set_quote @quote = @status.quotes.find_by!(status_id: params[:id]) end - def load_statuses + def set_statuses scope = default_statuses scope = scope.not_excluded_by_account(current_account) unless current_account.nil? - scope.merge(paginated_quotes).to_a + @statuses = scope.merge(paginated_quotes).to_a + + # Store next page info before filtering + @records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + @pagination_since_id = @statuses.first.quote.id unless @statuses.empty? + @pagination_max_id = @statuses.last.quote.id if @records_continue + + if current_account&.id != @status.account_id + domains = @statuses.filter_map(&:account_domain).uniq + account_ids = @statuses.map(&:account_id).uniq + relations = current_account&.relations_map(account_ids, domains) || {} + @statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? } + end end def default_statuses @@ -58,15 +66,9 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty? end - def pagination_max_id - @statuses.last.quote.id - end - - def pagination_since_id - @statuses.first.quote.id - end + attr_reader :pagination_max_id, :pagination_since_id def records_continue? - @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT) + @records_continue end end diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index b720b4746d..b5ff686f86 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -1,11 +1,15 @@ import { useCallback } from 'react'; +import classNames from 'classnames'; + import { useLinks } from 'mastodon/hooks/useLinks'; -import { EmojiHTML } from '../features/emoji/emoji_html'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; +import { AnimateEmojiProvider } from './emoji/context'; +import { EmojiHTML } from './emoji/html'; + interface AccountBioProps { className: string; accountId: string; @@ -44,13 +48,13 @@ export const AccountBio: React.FC = ({ } return ( -
-
+ ); }; diff --git a/app/javascript/mastodon/components/display_name/no-domain.tsx b/app/javascript/mastodon/components/display_name/no-domain.tsx index 3a66fe5042..bb5a093659 100644 --- a/app/javascript/mastodon/components/display_name/no-domain.tsx +++ b/app/javascript/mastodon/components/display_name/no-domain.tsx @@ -2,9 +2,10 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; import classNames from 'classnames'; -import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import { AnimateEmojiProvider } from '../emoji/context'; +import { EmojiHTML } from '../emoji/html'; import { Skeleton } from '../skeleton'; import type { DisplayNameProps } from './index'; @@ -14,9 +15,10 @@ export const DisplayNameWithoutDomain: FC< ComponentPropsWithoutRef<'span'> > = ({ account, className, children, ...props }) => { return ( - {account ? ( @@ -27,8 +29,8 @@ export const DisplayNameWithoutDomain: FC< ? account.get('display_name') : account.get('display_name_html') } - shallow as='strong' + extraEmojis={account.get('emojis')} /> ) : ( @@ -37,6 +39,6 @@ export const DisplayNameWithoutDomain: FC< )} {children} - + ); }; diff --git a/app/javascript/mastodon/components/display_name/simple.tsx b/app/javascript/mastodon/components/display_name/simple.tsx index 3190c4384b..375f4932b2 100644 --- a/app/javascript/mastodon/components/display_name/simple.tsx +++ b/app/javascript/mastodon/components/display_name/simple.tsx @@ -1,8 +1,9 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; -import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import { EmojiHTML } from '../emoji/html'; + import type { DisplayNameProps } from './index'; export const DisplayNameSimple: FC< @@ -12,12 +13,19 @@ export const DisplayNameSimple: FC< if (!account) { return null; } - const accountName = isModernEmojiEnabled() - ? account.get('display_name') - : account.get('display_name_html'); + return ( - + ); }; diff --git a/app/javascript/mastodon/components/emoji/context.tsx b/app/javascript/mastodon/components/emoji/context.tsx new file mode 100644 index 0000000000..9fda5714d9 --- /dev/null +++ b/app/javascript/mastodon/components/emoji/context.tsx @@ -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(null); + +// Polymorphic provider component +type AnimateEmojiProviderProps = Required & { + 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 = useCallback( + (event) => { + onMouseEnter?.(event); + if (!autoPlayGif) { + setAnimate(true); + } + }, + [onMouseEnter], + ); + const handleLeave: MouseEventHandler = 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 ( + + {children} + + ); + } + + return ( + + + {children} + + + ); + }, +); +AnimateEmojiProvider.displayName = 'AnimateEmojiProvider'; + +// Handle custom emoji +export const CustomEmojiContext = createContext({}); + +export const CustomEmojiProvider = ({ + children, + emojis: rawEmojis, +}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => { + const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]); + return ( + + {children} + + ); +}; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx new file mode 100644 index 0000000000..a6ecc869c1 --- /dev/null +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -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 = Omit< + ComponentPropsWithoutRef, + '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) => { + const contents = useMemo( + () => htmlStringToComponents(htmlString, { onText: textToEmojis }), + [htmlString], + ); + + return ( + + + {contents} + + + ); +}; + +export const LegacyEmojiHTML = ( + props: EmojiHTMLProps, +) => { + const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; + const Wrapper = asElement ?? 'div'; + return ( + + ); +}; + +export const EmojiHTML = isModernEmojiEnabled() + ? ModernEmojiHTML + : LegacyEmojiHTML; diff --git a/app/javascript/mastodon/components/emoji/index.tsx b/app/javascript/mastodon/components/emoji/index.tsx new file mode 100644 index 0000000000..e070eb30dd --- /dev/null +++ b/app/javascript/mastodon/components/emoji/index.tsx @@ -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 = ({ + 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 ; + } + return fallback; + } + + if (state.type === EMOJI_TYPE_CUSTOM) { + const shortcode = `:${state.code}:`; + return ( + {shortcode} + ); + } + + const src = unicodeHexToUrl(state.code, appState.darkTheme); + + return ( + {state.data.unicode} + ); +}; + +/** + * 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 ; + }); +} diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 15a9046848..97aaecd1aa 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -8,7 +8,6 @@ import { useIdentity } from '@/mastodon/identity_context'; import { fetchRelationships, followAccount, - unblockAccount, unmuteAccount, } from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; @@ -59,7 +58,8 @@ export const FollowButton: React.FC<{ accountId?: string; compact?: boolean; labelLength?: 'auto' | 'short' | 'long'; -}> = ({ accountId, compact, labelLength = 'auto' }) => { + className?: string; +}> = ({ accountId, compact, labelLength = 'auto', className }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { signedIn } = useIdentity(); @@ -96,12 +96,24 @@ export const FollowButton: React.FC<{ return; } else if (relationship.muting) { dispatch(unmuteAccount(accountId)); - } else if (account && (relationship.following || relationship.requested)) { + } else if (account && relationship.following) { dispatch( openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }), ); + } else if (account && relationship.requested) { + dispatch( + openModal({ + modalType: 'CONFIRM_WITHDRAW_REQUEST', + modalProps: { account }, + }), + ); } else if (relationship.blocking) { - dispatch(unblockAccount(accountId)); + dispatch( + openModal({ + modalType: 'CONFIRM_UNBLOCK', + modalProps: { account }, + }), + ); } else { dispatch(followAccount(accountId)); } @@ -144,7 +156,7 @@ export const FollowButton: React.FC<{ href='/settings/profile' target='_blank' rel='noopener' - className={classNames('button button-secondary', { + className={classNames(className, 'button button-secondary', { 'button--compact': compact, })} > @@ -158,13 +170,12 @@ export const FollowButton: React.FC<{ onClick={handleClick} disabled={ relationship?.blocked_by || - relationship?.blocking || (!(relationship?.following || relationship?.requested) && (account?.suspended || !!account?.moved)) } secondary={following} compact={compact} - className={following ? 'button--destructive' : undefined} + className={classNames(className, { 'button--destructive': following })} > {label} diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index af0059c7d6..d766793d87 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -13,10 +13,12 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react' import { Icon } from 'mastodon/components/icon'; import { Poll } from 'mastodon/components/poll'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state'; -import { EmojiHTML } from '../features/emoji/emoji_html'; +import { languages as preloadedLanguages } from 'mastodon/initial_state'; + import { isModernEmojiEnabled } from '../utils/environment'; +import { EmojiHTML } from './emoji/html'; + const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) /** diff --git a/app/javascript/mastodon/containers/compose_container.jsx b/app/javascript/mastodon/containers/compose_container.jsx index a2513cc552..3e6d20c74c 100644 --- a/app/javascript/mastodon/containers/compose_container.jsx +++ b/app/javascript/mastodon/containers/compose_container.jsx @@ -5,7 +5,7 @@ import { fetchServer } from 'mastodon/actions/server'; import { hydrateStore } from 'mastodon/actions/store'; import { Router } from 'mastodon/components/router'; import Compose from 'mastodon/features/standalone/compose'; -import initialState from 'mastodon/initial_state'; +import { initialState } from 'mastodon/initial_state'; import { IntlProvider } from 'mastodon/locales'; import { store } from 'mastodon/store'; diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index 086a7681c4..ee861366a5 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -13,7 +13,7 @@ import ErrorBoundary from 'mastodon/components/error_boundary'; import { Router } from 'mastodon/components/router'; import UI from 'mastodon/features/ui'; import { IdentityContext, createIdentityContext } from 'mastodon/identity_context'; -import initialState, { title as siteTitle } from 'mastodon/initial_state'; +import { initialState, title as siteTitle } from 'mastodon/initial_state'; import { IntlProvider } from 'mastodon/locales'; import { store } from 'mastodon/store'; import { isProduction } from 'mastodon/utils/environment'; diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index f58f1f4a8c..776157ccf5 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom'; import { AccountBio } from '@/mastodon/components/account_bio'; import { DisplayName } from '@/mastodon/components/display_name'; +import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import LockIcon from '@/material-icons/400-24px/lock.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; @@ -33,7 +34,6 @@ import { initMuteModal } from 'mastodon/actions/mutes'; import { initReport } from 'mastodon/actions/reports'; import { Avatar } from 'mastodon/components/avatar'; import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge'; -import { Button } from 'mastodon/components/button'; import { CopyIconButton } from 'mastodon/components/copy_icon_button'; import { FollowersCounter, @@ -383,7 +383,7 @@ export const AccountHeader: React.FC<{ const isRemote = account?.acct !== account?.username; const remoteDomain = isRemote ? account?.acct.split('@')[1] : null; - const menu = useMemo(() => { + const menuItems = useMemo(() => { const arr: MenuItem[] = []; if (!account) { @@ -605,6 +605,15 @@ export const AccountHeader: React.FC<{ handleUnblockDomain, ]); + const menu = accountId !== me && ( + + ); + if (!account) { return null; } @@ -718,21 +727,16 @@ export const AccountHeader: React.FC<{ ); } - if (relationship?.blocking) { + const isMovedAndUnfollowedAccount = account.moved && !relationship?.following; + + if (!isMovedAndUnfollowedAccount) { actionBtn = ( -