From ffac4cb05f5c5a8f91074c846b74fc1dd0c5faf3 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 11:31:10 +0200 Subject: [PATCH 01/44] Emoji: Link Replacement (#36341) --- .../mastodon/components/account_bio.tsx | 40 ++++-- .../mastodon/components/emoji/html.tsx | 99 +++++++++------ .../status/handled_link.stories.tsx | 65 ++++++++++ .../components/status/handled_link.tsx | 79 ++++++++++++ .../mastodon/components/status_content.jsx | 49 ++++++-- .../mastodon/utils/__tests__/html-test.ts | 14 ++- app/javascript/mastodon/utils/html.ts | 116 ++++++++++-------- 7 files changed, 346 insertions(+), 116 deletions(-) create mode 100644 app/javascript/mastodon/components/status/handled_link.stories.tsx create mode 100644 app/javascript/mastodon/components/status/handled_link.tsx diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index b5ff686f86..64e5cc0457 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -6,9 +6,10 @@ import { useLinks } from 'mastodon/hooks/useLinks'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; +import type { OnElementHandler } from '../utils/html'; -import { AnimateEmojiProvider } from './emoji/context'; import { EmojiHTML } from './emoji/html'; +import { HandledLink } from './status/handled_link'; interface AccountBioProps { className: string; @@ -24,13 +25,37 @@ export const AccountBio: React.FC = ({ const handleClick = useLinks(showDropdown); const handleNodeChange = useCallback( (node: HTMLDivElement | null) => { - if (!showDropdown || !node || node.childNodes.length === 0) { + if ( + !showDropdown || + !node || + node.childNodes.length === 0 || + isModernEmojiEnabled() + ) { return; } addDropdownToHashtags(node, accountId); }, [showDropdown, accountId], ); + + const handleLink = useCallback( + (element, { key, ...props }) => { + if (element instanceof HTMLAnchorElement) { + return ( + + ); + } + return undefined; + }, + [accountId], + ); + const note = useAppSelector((state) => { const account = state.accounts.get(accountId); if (!account) { @@ -48,13 +73,14 @@ export const AccountBio: React.FC = ({ } return ( - - - + onElement={handleLink} + /> ); }; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index a6ecc869c1..73ad5fa233 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -1,60 +1,79 @@ 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 type { OnElementHandler } from '@/mastodon/utils/html'; import { htmlStringToComponents } from '@/mastodon/utils/html'; +import { polymorphicForwardRef } from '@/types/polymorphic'; import { AnimateEmojiProvider, CustomEmojiProvider } from './context'; import { textToEmojis } from './index'; -type EmojiHTMLProps = Omit< - ComponentPropsWithoutRef, - 'dangerouslySetInnerHTML' | 'className' -> & { +interface EmojiHTMLProps { htmlString: string; extraEmojis?: CustomEmojiMapArg; - as?: Element; className?: string; -}; + onElement?: OnElementHandler; +} -export const ModernEmojiHTML = ({ - extraEmojis, - htmlString, - as: asProp = 'div', // Rename for syntax highlighting - shallow, - className = '', - ...props -}: EmojiHTMLProps) => { - const contents = useMemo( - () => htmlStringToComponents(htmlString, { onText: textToEmojis }), - [htmlString], - ); +export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + ( + { + extraEmojis, + htmlString, + as: asProp = 'div', // Rename for syntax highlighting + className = '', + onElement, + ...props + }, + ref, + ) => { + const contents = useMemo( + () => + htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }), + [htmlString, onElement], + ); - return ( - - - {contents} - - - ); -}; + return ( + + + {contents} + + + ); + }, +); +ModernEmojiHTML.displayName = 'ModernEmojiHTML'; -export const LegacyEmojiHTML = ( - props: EmojiHTMLProps, -) => { - const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; - const Wrapper = asElement ?? 'div'; - return ( - - ); -}; +export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + (props, ref) => { + const { + as: asElement, + htmlString, + extraEmojis, + className, + onElement, + ...rest + } = props; + const Wrapper = asElement ?? 'div'; + return ( + + ); + }, +); +LegacyEmojiHTML.displayName = 'LegacyEmojiHTML'; export const EmojiHTML = isModernEmojiEnabled() ? ModernEmojiHTML diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx new file mode 100644 index 0000000000..a45e33626a --- /dev/null +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller'; +import { accountFactoryState } from '@/testing/factories'; + +import { HoverCardController } from '../hover_card_controller'; + +import type { HandledLinkProps } from './handled_link'; +import { HandledLink } from './handled_link'; + +const meta = { + title: 'Components/Status/HandledLink', + render(args) { + return ( + <> + + + + + ); + }, + args: { + href: 'https://example.com/path/subpath?query=1#hash', + text: 'https://example.com', + }, + parameters: { + state: { + accounts: { + '1': accountFactoryState(), + }, + }, + }, +} satisfies Meta>; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Hashtag: Story = { + args: { + text: '#example', + }, +}; + +export const Mention: Story = { + args: { + text: '@user', + }, +}; + +export const InternalLink: Story = { + args: { + href: '/about', + text: 'About', + }, +}; + +export const InvalidURL: Story = { + args: { + href: 'ht!tp://invalid-url', + text: 'ht!tp://invalid-url -- invalid!', + }, +}; diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx new file mode 100644 index 0000000000..ee41321283 --- /dev/null +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -0,0 +1,79 @@ +import type { ComponentProps, FC } from 'react'; + +import { Link } from 'react-router-dom'; + +export interface HandledLinkProps { + href: string; + text: string; + hashtagAccountId?: string; + mentionAccountId?: string; +} + +export const HandledLink: FC> = ({ + href, + text, + hashtagAccountId, + mentionAccountId, + ...props +}) => { + // Handle hashtags + if (text.startsWith('#')) { + const hashtag = text.slice(1).trim(); + return ( + + #{hashtag} + + ); + } else if (text.startsWith('@')) { + // Handle mentions + const mention = text.slice(1).trim(); + return ( + + @{mention} + + ); + } + + // Non-absolute paths treated as internal links. + if (href.startsWith('/')) { + return ( + + {text} + + ); + } + + try { + const url = new URL(href); + const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. + return ( + + {url.protocol + '//'} + {`${url.hostname}/${first ?? ''}`} + {'/' + rest.join('/')} + + ); + } catch { + return text; + } +}; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index d766793d87..93c0e77bd7 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state'; import { isModernEmojiEnabled } from '../utils/environment'; import { EmojiHTML } from './emoji/html'; +import { HandledLink } from './status/handled_link'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) @@ -99,6 +100,23 @@ class StatusContent extends PureComponent { } const { status, onCollapsedToggle } = this.props; + if (status.get('collapsed', null) === null && onCollapsedToggle) { + const { collapsible, onClick } = this.props; + + const collapsed = + collapsible + && onClick + && node.clientHeight > MAX_HEIGHT + && status.get('spoiler_text').length === 0; + + onCollapsedToggle(collapsed); + } + + // Exit if modern emoji is enabled, as it handles links using the HandledLink component. + if (isModernEmojiEnabled()) { + return; + } + const links = node.querySelectorAll('a'); let link, mention; @@ -128,18 +146,6 @@ class StatusContent extends PureComponent { link.classList.add('unhandled-link'); } } - - if (status.get('collapsed', null) === null && onCollapsedToggle) { - const { collapsible, onClick } = this.props; - - const collapsed = - collapsible - && onClick - && node.clientHeight > MAX_HEIGHT - && status.get('spoiler_text').length === 0; - - onCollapsedToggle(collapsed); - } } componentDidMount () { @@ -201,6 +207,23 @@ class StatusContent extends PureComponent { this.node = c; }; + handleElement = (element, {key, ...props}) => { + if (element instanceof HTMLAnchorElement) { + const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); + return ( + + ); + } + return undefined; + } + render () { const { status, intl, statusContent } = this.props; @@ -245,6 +268,7 @@ class StatusContent extends PureComponent { lang={language} htmlString={content} extraEmojis={status.get('emojis')} + onElement={this.handleElement.bind(this)} /> {poll} @@ -262,6 +286,7 @@ class StatusContent extends PureComponent { lang={language} htmlString={content} extraEmojis={status.get('emojis')} + onElement={this.handleElement.bind(this)} /> {poll} diff --git a/app/javascript/mastodon/utils/__tests__/html-test.ts b/app/javascript/mastodon/utils/__tests__/html-test.ts index 6aacc396dc..a48a8b572b 100644 --- a/app/javascript/mastodon/utils/__tests__/html-test.ts +++ b/app/javascript/mastodon/utils/__tests__/html-test.ts @@ -53,13 +53,19 @@ describe('html', () => { it('calls onElement callback', () => { const input = '

lorem ipsum

'; - const onElement = vi.fn( - (element: HTMLElement, children: React.ReactNode[]) => - React.createElement(element.tagName.toLowerCase(), {}, ...children), + const onElement = vi.fn( + (element, props, children) => + React.createElement( + element.tagName.toLowerCase(), + props, + ...children, + ), ); html.htmlStringToComponents(input, { onElement }); expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ key: expect.any(String) }), expect.arrayContaining(['lorem ipsum']), {}, ); @@ -71,6 +77,8 @@ describe('html', () => { const output = html.htmlStringToComponents(input, { onElement }); expect(onElement).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ tagName: 'P' }), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + expect.objectContaining({ key: expect.any(String) }), expect.arrayContaining(['lorem ipsum']), {}, ); diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index 971aefa6d1..f37018d86d 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -32,14 +32,21 @@ interface QueueItem { depth: number; } -export interface HTMLToStringOptions> { +export type OnElementHandler< + Arg extends Record = Record, +> = ( + element: HTMLElement, + props: Record, + children: React.ReactNode[], + extra: Arg, +) => React.ReactNode; + +export interface HTMLToStringOptions< + Arg extends Record = Record, +> { maxDepth?: number; onText?: (text: string, extra: Arg) => React.ReactNode; - onElement?: ( - element: HTMLElement, - children: React.ReactNode[], - extra: Arg, - ) => React.ReactNode; + onElement?: OnElementHandler; onAttribute?: ( name: string, value: string, @@ -125,9 +132,57 @@ export function htmlStringToComponents>( const children: React.ReactNode[] = []; let element: React.ReactNode = undefined; + // Generate props from attributes. + const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. + const props: Record = { key }; + for (const attr of node.attributes) { + let name = attr.name.toLowerCase(); + + // Custom attribute handler. + if (onAttribute) { + const result = onAttribute( + name, + attr.value, + node.tagName.toLowerCase(), + extraArgs, + ); + if (result) { + const [cbName, value] = result; + props[cbName] = value; + } + } else { + // Check global attributes first, then tag-specific ones. + const globalAttr = globalAttributes[name]; + const tagAttr = tagInfo.attributes?.[name]; + + // Exit if neither global nor tag-specific attribute is allowed. + if (!globalAttr && !tagAttr) { + continue; + } + + // Rename if needed. + if (typeof tagAttr === 'string') { + name = tagAttr; + } else if (typeof globalAttr === 'string') { + name = globalAttr; + } + + let value: string | boolean | number = attr.value; + + // Handle boolean attributes. + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } + + props[name] = value; + } + } + // If onElement is provided, use it to create the element. if (onElement) { - const component = onElement(node, children, extraArgs); + const component = onElement(node, props, children, extraArgs); // Check for undefined to allow returning null. if (component !== undefined) { @@ -137,53 +192,6 @@ export function htmlStringToComponents>( // If the element wasn't created, use the default conversion. if (element === undefined) { - const props: Record = {}; - props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. - for (const attr of node.attributes) { - let name = attr.name.toLowerCase(); - - // Custom attribute handler. - if (onAttribute) { - const result = onAttribute( - name, - attr.value, - node.tagName.toLowerCase(), - extraArgs, - ); - if (result) { - const [cbName, value] = result; - props[cbName] = value; - } - } else { - // Check global attributes first, then tag-specific ones. - const globalAttr = globalAttributes[name]; - const tagAttr = tagInfo.attributes?.[name]; - - // Exit if neither global nor tag-specific attribute is allowed. - if (!globalAttr && !tagAttr) { - continue; - } - - // Rename if needed. - if (typeof tagAttr === 'string') { - name = tagAttr; - } else if (typeof globalAttr === 'string') { - name = globalAttr; - } - - let value: string | boolean | number = attr.value; - - // Handle boolean attributes. - if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; - } - - props[name] = value; - } - } - element = React.createElement( tagName, props, From 68a36d5a57269e34c76305b49628a36a87c21b74 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 15:34:51 +0200 Subject: [PATCH 02/44] Allow modern_emojis to be enabled purely server-side (#36342) --- app/javascript/mastodon/utils/environment.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/javascript/mastodon/utils/environment.ts b/app/javascript/mastodon/utils/environment.ts index c666e2c94d..c2b6b1cf86 100644 --- a/app/javascript/mastodon/utils/environment.ts +++ b/app/javascript/mastodon/utils/environment.ts @@ -20,10 +20,7 @@ export function isFeatureEnabled(feature: Features) { export function isModernEmojiEnabled() { try { - return ( - isFeatureEnabled('modern_emojis') && - localStorage.getItem('experiments')?.split(',').includes('modern_emojis') - ); + return isFeatureEnabled('modern_emojis'); } catch { return false; } From cda07686dfbbc0b3f8dfbb38a54efa3d126f2c44 Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 6 Oct 2025 15:43:20 +0200 Subject: [PATCH 03/44] Add feature to automatically attach quote on eligible link past in Web UI composer (#36364) --- .../mastodon/actions/compose_typed.ts | 37 +++++++++++++++++++ .../components/autosuggest_textarea.jsx | 5 +-- .../containers/compose_form_container.js | 20 +++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 7f70a1bd48..ed925914f9 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { apiUpdateMedia } from 'mastodon/api/compose'; +import { apiGetSearch } from 'mastodon/api/search'; import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments'; import type { MediaAttachment } from 'mastodon/models/media_attachment'; import { @@ -16,6 +17,7 @@ import type { Status } from '../models/status'; import { showAlert } from './alerts'; import { focusCompose } from './compose'; +import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; const messages = defineMessages({ @@ -165,6 +167,41 @@ export const quoteComposeById = createAppThunk( }, ); +export const pasteLinkCompose = createDataLoadingThunk( + 'compose/pasteLink', + async ({ url }: { url: string }) => { + return await apiGetSearch({ + q: url, + type: 'statuses', + resolve: true, + limit: 2, + }); + }, + (data, { dispatch, getState }) => { + const composeState = getState().compose; + + if ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') + ) + return; + + dispatch(importFetchedStatuses(data.statuses)); + + if ( + data.statuses.length === 1 && + data.statuses[0] && + ['automatic', 'manual'].includes( + data.statuses[0].quote_approval?.current_user ?? 'denied', + ) + ) { + dispatch(quoteComposeById(data.statuses[0].id)); + } + }, +); + export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); export const setComposeQuotePolicy = createAction( diff --git a/app/javascript/mastodon/components/autosuggest_textarea.jsx b/app/javascript/mastodon/components/autosuggest_textarea.jsx index de5accc4b2..68cf9e17fc 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.jsx +++ b/app/javascript/mastodon/components/autosuggest_textarea.jsx @@ -150,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({ }, [suggestions, onSuggestionSelected, textareaRef]); const handlePaste = useCallback((e) => { - if (e.clipboardData && e.clipboardData.files.length === 1) { - onPaste(e.clipboardData.files); - e.preventDefault(); - } + onPaste(e); }, [onPaste]); // Show the suggestions again whenever they change and the textarea is focused diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 5f86426c4d..3dad46bc52 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -10,10 +10,13 @@ import { insertEmojiCompose, uploadCompose, } from 'mastodon/actions/compose'; +import { pasteLinkCompose } from 'mastodon/actions/compose_typed'; import { openModal } from 'mastodon/actions/modal'; import ComposeForm from '../components/compose_form'; +const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i; + const mapStateToProps = state => ({ text: state.getIn(['compose', 'text']), suggestions: state.getIn(['compose', 'suggestions']), @@ -71,8 +74,21 @@ const mapDispatchToProps = (dispatch, props) => ({ dispatch(changeComposeSpoilerText(checked)); }, - onPaste (files) { - dispatch(uploadCompose(files)); + onPaste (e) { + if (e.clipboardData && e.clipboardData.files.length === 1) { + dispatch(uploadCompose(e.clipboardData.files)); + e.preventDefault(); + } else if (e.clipboardData && e.clipboardData.files.length === 0) { + const data = e.clipboardData.getData('text/plain'); + if (!data.match(urlLikeRegex)) return; + + try { + const url = new URL(data); + dispatch(pasteLinkCompose({ url })); + } catch { + return; + } + } }, onPickEmoji (position, data, needsSpace) { From 4a40f810670c60c202fd79ef7f3f35a102da6f2b Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 6 Oct 2025 16:10:26 +0200 Subject: [PATCH 04/44] Link to local accounts from settings (#36340) --- app/helpers/home_helper.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 79e28c983a..59bc06031e 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -21,7 +21,13 @@ module HomeHelper end end else - link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do + account_url = if account.suspended? + ActivityPub::TagManager.instance.url_for(account) + else + web_url("@#{account.pretty_acct}") + end + + link_to(path || account_url, class: 'account__display-name') do content_tag(:div, class: 'account__avatar-wrapper') do image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46) end + From 474fbb2770a4bff97e25d07897a21acfda373262 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 6 Oct 2025 16:13:24 +0200 Subject: [PATCH 05/44] Fetch all replies: Only display "More replies found" prompt when there really are new replies (#36334) --- .../mastodon/actions/statuses_typed.ts | 14 +- .../status/components/refresh_controller.tsx | 120 +++++++++--------- app/javascript/mastodon/locales/en.json | 1 - app/javascript/mastodon/reducers/contexts.ts | 109 ++++++++++++---- 4 files changed, 155 insertions(+), 89 deletions(-) diff --git a/app/javascript/mastodon/actions/statuses_typed.ts b/app/javascript/mastodon/actions/statuses_typed.ts index f34d9f2bc3..be9bec71bb 100644 --- a/app/javascript/mastodon/actions/statuses_typed.ts +++ b/app/javascript/mastodon/actions/statuses_typed.ts @@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', - ({ statusId }: { statusId: string }) => apiGetContext(statusId), - ({ context, refresh }, { dispatch }) => { + ({ statusId }: { statusId: string; prefetchOnly?: boolean }) => + apiGetContext(statusId), + ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => { const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); @@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk( return { context, refresh, + prefetchOnly, }; }, ); @@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>( 'status/context/complete', ); +export const showPendingReplies = createAction<{ statusId: string }>( + 'status/context/showPendingReplies', +); + +export const clearPendingReplies = createAction<{ statusId: string }>( + 'status/context/clearPendingReplies', +); + export const setStatusQuotePolicy = createDataLoadingThunk( 'status/setQuotePolicy', ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { diff --git a/app/javascript/mastodon/features/status/components/refresh_controller.tsx b/app/javascript/mastodon/features/status/components/refresh_controller.tsx index 34faaf1d5d..8297922cbb 100644 --- a/app/javascript/mastodon/features/status/components/refresh_controller.tsx +++ b/app/javascript/mastodon/features/status/components/refresh_controller.tsx @@ -5,6 +5,8 @@ import { useIntl, defineMessages } from 'react-intl'; import { fetchContext, completeContextRefresh, + showPendingReplies, + clearPendingReplies, } from 'mastodon/actions/statuses'; import type { AsyncRefreshHeader } from 'mastodon/api'; import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes'; @@ -34,10 +36,6 @@ const messages = defineMessages({ id: 'status.context.loading', defaultMessage: 'Loading', }, - loadingMore: { - id: 'status.context.loading_more', - defaultMessage: 'Loading more replies', - }, success: { id: 'status.context.loading_success', defaultMessage: 'All replies loaded', @@ -52,36 +50,33 @@ const messages = defineMessages({ }, }); -type LoadingState = - | 'idle' - | 'more-available' - | 'loading-initial' - | 'loading-more' - | 'success' - | 'error'; +type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error'; export const RefreshController: React.FC<{ statusId: string; }> = ({ statusId }) => { - const refresh = useAppSelector( - (state) => state.contexts.refreshing[statusId], - ); - const currentReplyCount = useAppSelector( - (state) => state.contexts.replies[statusId]?.length ?? 0, - ); - const autoRefresh = !currentReplyCount; const dispatch = useAppDispatch(); const intl = useIntl(); - const [loadingState, setLoadingState] = useState( - refresh && autoRefresh ? 'loading-initial' : 'idle', + const refreshHeader = useAppSelector( + (state) => state.contexts.refreshing[statusId], ); + const hasPendingReplies = useAppSelector( + (state) => !!state.contexts.pendingReplies[statusId]?.length, + ); + const [partialLoadingState, setLoadingState] = useState( + refreshHeader ? 'loading' : 'idle', + ); + const loadingState = hasPendingReplies + ? 'more-available' + : partialLoadingState; const [wasDismissed, setWasDismissed] = useState(false); const dismissPrompt = useCallback(() => { setWasDismissed(true); setLoadingState('idle'); - }, []); + dispatch(clearPendingReplies({ statusId })); + }, [dispatch, statusId]); useEffect(() => { let timeoutId: ReturnType; @@ -89,36 +84,51 @@ export const RefreshController: React.FC<{ const scheduleRefresh = (refresh: AsyncRefreshHeader) => { timeoutId = setTimeout(() => { void apiGetAsyncRefresh(refresh.id).then((result) => { - if (result.async_refresh.status === 'finished') { - dispatch(completeContextRefresh({ statusId })); - - if (result.async_refresh.result_count > 0) { - if (autoRefresh) { - void dispatch(fetchContext({ statusId })).then(() => { - setLoadingState('idle'); - }); - } else { - setLoadingState('more-available'); - } - } else { - setLoadingState('idle'); - } - } else { + // If the refresh status is not finished, + // schedule another refresh and exit + if (result.async_refresh.status !== 'finished') { scheduleRefresh(refresh); + return; } + + // Refresh status is finished. The action below will clear `refreshHeader` + dispatch(completeContextRefresh({ statusId })); + + // Exit if there's nothing to fetch + if (result.async_refresh.result_count === 0) { + setLoadingState('idle'); + return; + } + + // A positive result count means there _might_ be new replies, + // so we fetch the context in the background to check if there + // are any new replies. + // If so, they will populate `contexts.pendingReplies[statusId]` + void dispatch(fetchContext({ statusId, prefetchOnly: true })) + .then(() => { + // Reset loading state to `idle` – but if the fetch + // has resulted in new pending replies, the `hasPendingReplies` + // flag will switch the loading state to 'more-available' + setLoadingState('idle'); + }) + .catch(() => { + // Show an error if the fetch failed + setLoadingState('error'); + }); }); }, refresh.retry * 1000); }; - if (refresh && !wasDismissed) { - scheduleRefresh(refresh); - setLoadingState('loading-initial'); + // Initialise a refresh + if (refreshHeader && !wasDismissed) { + scheduleRefresh(refreshHeader); + setLoadingState('loading'); } return () => { clearTimeout(timeoutId); }; - }, [dispatch, statusId, refresh, autoRefresh, wasDismissed]); + }, [dispatch, statusId, refreshHeader, wasDismissed]); useEffect(() => { // Hide success message after a short delay @@ -134,20 +144,19 @@ export const RefreshController: React.FC<{ return () => ''; }, [loadingState]); - const handleClick = useCallback(() => { - setLoadingState('loading-more'); - - dispatch(fetchContext({ statusId })) - .then(() => { - setLoadingState('success'); - return ''; - }) - .catch(() => { - setLoadingState('error'); - }); + useEffect(() => { + // Clear pending replies on unmount + return () => { + dispatch(clearPendingReplies({ statusId })); + }; }, [dispatch, statusId]); - if (loadingState === 'loading-initial') { + const handleClick = useCallback(() => { + dispatch(showPendingReplies({ statusId })); + setLoadingState('success'); + }, [dispatch, statusId]); + + if (loadingState === 'loading') { return (
- ; replies: Record; + pendingReplies: Record< + string, + Pick[] + >; refreshing: Record; } const initialState: State = { inReplyTos: {}, replies: {}, + pendingReplies: {}, refreshing: {}, }; +const addReply = ( + state: Draft, + { id, in_reply_to_id }: Pick, +) => { + if (!in_reply_to_id) { + return; + } + + if (!state.inReplyTos[id]) { + const siblings = (state.replies[in_reply_to_id] ??= []); + const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0); + siblings.splice(index + 1, 0, id); + state.inReplyTos[id] = in_reply_to_id; + } +}; + const normalizeContext = ( state: Draft, id: string, { ancestors, descendants }: ApiContextJSON, ): void => { - const addReply = ({ - id, - in_reply_to_id, - }: { - id: string; - in_reply_to_id?: string; - }) => { - if (!in_reply_to_id) { - return; - } - - if (!state.inReplyTos[id]) { - const siblings = (state.replies[in_reply_to_id] ??= []); - const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0); - siblings.splice(index + 1, 0, id); - state.inReplyTos[id] = in_reply_to_id; - } - }; + ancestors.forEach((item) => { + addReply(state, item); + }); // We know in_reply_to_id of statuses but `id` itself. // So we assume that the status of the id replies to last ancestors. - - ancestors.forEach(addReply); - if (ancestors[0]) { - addReply({ + addReply(state, { id, in_reply_to_id: ancestors[ancestors.length - 1]?.id, }); } - descendants.forEach(addReply); + descendants.forEach((item) => { + addReply(state, item); + }); +}; + +const applyPrefetchedReplies = (state: Draft, statusId: string) => { + const pendingReplies = state.pendingReplies[statusId]; + if (pendingReplies?.length) { + pendingReplies.forEach((item) => { + addReply(state, item); + }); + delete state.pendingReplies[statusId]; + } +}; + +const storePrefetchedReplies = ( + state: Draft, + statusId: string, + { descendants }: ApiContextJSON, +): void => { + descendants.forEach(({ id, in_reply_to_id }) => { + if (!in_reply_to_id) { + return; + } + const isNewReply = !state.replies[in_reply_to_id]?.includes(id); + if (isNewReply) { + const pendingReplies = (state.pendingReplies[statusId] ??= []); + pendingReplies.push({ id, in_reply_to_id }); + } + }); }; const deleteFromContexts = (state: Draft, ids: string[]): void => { @@ -129,12 +166,30 @@ const updateContext = (state: Draft, status: ApiStatusJSON): void => { export const contextsReducer = createReducer(initialState, (builder) => { builder .addCase(fetchContext.fulfilled, (state, action) => { - normalizeContext(state, action.meta.arg.statusId, action.payload.context); + if (action.payload.prefetchOnly) { + storePrefetchedReplies( + state, + action.meta.arg.statusId, + action.payload.context, + ); + } else { + normalizeContext( + state, + action.meta.arg.statusId, + action.payload.context, + ); - if (action.payload.refresh) { - state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + if (action.payload.refresh) { + state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + } } }) + .addCase(showPendingReplies, (state, action) => { + applyPrefetchedReplies(state, action.payload.statusId); + }) + .addCase(clearPendingReplies, (state, action) => { + delete state.pendingReplies[action.payload.statusId]; + }) .addCase(completeContextRefresh, (state, action) => { delete state.refreshing[action.payload.statusId]; }) From 9027d604204121808019e4f9b45e5e86565e7f3d Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 18:20:15 +0200 Subject: [PATCH 06/44] Emoji: Remove re: from handleElement in StatusContent (#36366) --- app/javascript/mastodon/components/status_content.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 93c0e77bd7..5b10d45e84 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -207,7 +207,7 @@ class StatusContent extends PureComponent { this.node = c; }; - handleElement = (element, {key, ...props}) => { + handleElement = (element, { key, ...props }) => { if (element instanceof HTMLAnchorElement) { const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); return ( @@ -220,6 +220,8 @@ class StatusContent extends PureComponent { key={key} /> ); + } else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) { + return null; } return undefined; } From e8dab026bbc2ec46eb82a78770994ad9a3512c9a Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Tue, 7 Oct 2025 12:19:53 +0200 Subject: [PATCH 07/44] Fix quote mailer preview to use the latest quote notification (#36373) --- spec/mailers/previews/notification_mailer_preview.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/mailers/previews/notification_mailer_preview.rb b/spec/mailers/previews/notification_mailer_preview.rb index ae2d6802bc..29ab047dfb 100644 --- a/spec/mailers/previews/notification_mailer_preview.rb +++ b/spec/mailers/previews/notification_mailer_preview.rb @@ -35,7 +35,9 @@ class NotificationMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/notification_mailer/quote def quote - activity = Quote.first + notification = Notification.where(type: 'quote').order(:created_at).last + activity = notification.activity + mailer_for(activity.quoted_account, activity).quote end From a7f89d13d24cc8d4a52ee6049ea5b943ab9ea594 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 7 Oct 2025 14:37:40 +0200 Subject: [PATCH 08/44] Change index on `follows` table to improve performance of some queries (#36374) --- ..._index_follows_on_target_account_id_and_account_id.rb | 9 +++++++++ ...07100813_remove_index_follows_on_target_account_id.rb | 9 +++++++++ db/schema.rb | 4 ++-- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20251007100627_add_index_follows_on_target_account_id_and_account_id.rb create mode 100644 db/migrate/20251007100813_remove_index_follows_on_target_account_id.rb diff --git a/db/migrate/20251007100627_add_index_follows_on_target_account_id_and_account_id.rb b/db/migrate/20251007100627_add_index_follows_on_target_account_id_and_account_id.rb new file mode 100644 index 0000000000..23c08d2d06 --- /dev/null +++ b/db/migrate/20251007100627_add_index_follows_on_target_account_id_and_account_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIndexFollowsOnTargetAccountIdAndAccountId < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :follows, [:target_account_id, :account_id], algorithm: :concurrently + end +end diff --git a/db/migrate/20251007100813_remove_index_follows_on_target_account_id.rb b/db/migrate/20251007100813_remove_index_follows_on_target_account_id.rb new file mode 100644 index 0000000000..142086f358 --- /dev/null +++ b/db/migrate/20251007100813_remove_index_follows_on_target_account_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemoveIndexFollowsOnTargetAccountId < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + remove_index :follows, [:target_account_id], algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index af60a1b11b..47937f8657 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_02_140103) do +ActiveRecord::Schema[8.0].define(version: 2025_10_07_100813) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -571,7 +571,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_02_140103) do t.boolean "notify", default: false, null: false t.string "languages", array: true t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true - t.index ["target_account_id"], name: "index_follows_on_target_account_id" + t.index ["target_account_id", "account_id"], name: "index_follows_on_target_account_id_and_account_id" end create_table "generated_annual_reports", force: :cascade do |t| From adcbab527ab87f84ea998b50949848f03285df58 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:37:46 +0200 Subject: [PATCH 09/44] New Crowdin Translations (automated) (#36371) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/be.json | 1 - app/javascript/mastodon/locales/br.json | 7 +++ app/javascript/mastodon/locales/ca.json | 1 - app/javascript/mastodon/locales/cs.json | 1 - app/javascript/mastodon/locales/cy.json | 1 - app/javascript/mastodon/locales/da.json | 1 - app/javascript/mastodon/locales/de.json | 1 - app/javascript/mastodon/locales/el.json | 1 - app/javascript/mastodon/locales/es-AR.json | 1 - app/javascript/mastodon/locales/es-MX.json | 1 - app/javascript/mastodon/locales/es.json | 1 - app/javascript/mastodon/locales/et.json | 1 - app/javascript/mastodon/locales/fi.json | 1 - app/javascript/mastodon/locales/fo.json | 1 - app/javascript/mastodon/locales/fr-CA.json | 17 ++++++ app/javascript/mastodon/locales/fr.json | 17 ++++++ app/javascript/mastodon/locales/ga.json | 1 - app/javascript/mastodon/locales/gl.json | 1 - app/javascript/mastodon/locales/he.json | 1 - app/javascript/mastodon/locales/hu.json | 1 - app/javascript/mastodon/locales/ia.json | 1 - app/javascript/mastodon/locales/is.json | 1 - app/javascript/mastodon/locales/it.json | 1 - app/javascript/mastodon/locales/nan.json | 1 - app/javascript/mastodon/locales/nl.json | 1 - app/javascript/mastodon/locales/nn.json | 1 - app/javascript/mastodon/locales/pt-PT.json | 1 - app/javascript/mastodon/locales/sq.json | 70 +++++++++++++++++++++- app/javascript/mastodon/locales/tr.json | 3 +- app/javascript/mastodon/locales/vi.json | 1 - app/javascript/mastodon/locales/zh-CN.json | 1 - app/javascript/mastodon/locales/zh-TW.json | 1 - config/locales/be.yml | 4 ++ config/locales/br.yml | 1 + config/locales/cs.yml | 4 ++ config/locales/da.yml | 6 +- config/locales/de.yml | 4 ++ config/locales/el.yml | 4 ++ config/locales/es-AR.yml | 4 ++ config/locales/es-MX.yml | 4 ++ config/locales/es.yml | 4 ++ config/locales/fi.yml | 4 ++ config/locales/fo.yml | 4 ++ config/locales/ga.yml | 4 ++ config/locales/gl.yml | 4 ++ config/locales/he.yml | 4 ++ config/locales/is.yml | 4 ++ config/locales/simple_form.cs.yml | 4 ++ config/locales/simple_form.da.yml | 4 ++ config/locales/simple_form.de.yml | 4 ++ config/locales/simple_form.el.yml | 4 ++ config/locales/simple_form.es-AR.yml | 4 ++ config/locales/simple_form.es-MX.yml | 4 ++ config/locales/simple_form.es.yml | 4 ++ config/locales/simple_form.fi.yml | 4 ++ config/locales/simple_form.fo.yml | 4 ++ config/locales/simple_form.ga.yml | 4 ++ config/locales/simple_form.gl.yml | 4 ++ config/locales/simple_form.he.yml | 4 ++ config/locales/simple_form.is.yml | 4 ++ config/locales/simple_form.sq.yml | 4 ++ config/locales/simple_form.tr.yml | 4 ++ config/locales/simple_form.vi.yml | 4 ++ config/locales/simple_form.zh-CN.yml | 4 ++ config/locales/simple_form.zh-TW.yml | 4 ++ config/locales/sq.yml | 30 ++++++++++ config/locales/tr.yml | 4 ++ config/locales/vi.yml | 4 ++ config/locales/zh-CN.yml | 6 +- config/locales/zh-TW.yml | 4 ++ 70 files changed, 289 insertions(+), 31 deletions(-) diff --git a/app/javascript/mastodon/locales/be.json b/app/javascript/mastodon/locales/be.json index d43f53bbaa..dc02cf731a 100644 --- a/app/javascript/mastodon/locales/be.json +++ b/app/javascript/mastodon/locales/be.json @@ -875,7 +875,6 @@ "status.contains_quote": "Утрымлівае цытату", "status.context.loading": "Загружаюцца іншыя адказы", "status.context.loading_error": "Немагчыма загрузіць новыя адказы", - "status.context.loading_more": "Загружаюцца іншыя адказы", "status.context.loading_success": "Усе адказы загружаныя", "status.context.more_replies_found": "Знойдзеныя іншыя адказы", "status.context.retry": "Паспрабаваць зноў", diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json index c33d64a54e..79eb948ba8 100644 --- a/app/javascript/mastodon/locales/br.json +++ b/app/javascript/mastodon/locales/br.json @@ -28,6 +28,7 @@ "account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}", "account.domain_blocking": "Domani stanket", "account.edit_profile": "Kemmañ ar profil", + "account.edit_profile_short": "Kemmañ", "account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}", "account.endorse": "Lakaat en a-raok war ar profil", "account.familiar_followers_one": "Heuliet gant {name1}", @@ -39,6 +40,7 @@ "account.featured_tags.last_status_never": "Embann ebet", "account.follow": "Heuliañ", "account.follow_back": "Heuliañ d'ho tro", + "account.follow_request_cancel_short": "Nullañ", "account.followers": "Tud koumanantet", "account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.", "account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}", @@ -216,7 +218,12 @@ "confirmations.remove_from_followers.title": "Dilemel an heulier·ez?", "confirmations.revoke_quote.confirm": "Dilemel an embannadur", "confirmations.revoke_quote.title": "Dilemel an embannadur?", + "confirmations.unblock.confirm": "Distankañ", + "confirmations.unblock.title": "Distankañ {name}?", "confirmations.unfollow.confirm": "Diheuliañ", + "confirmations.unfollow.title": "Diheuliañ {name}?", + "confirmations.withdraw_request.confirm": "Nullañ ar reked", + "confirmations.withdraw_request.title": "Nullañ ho reked da heuliañ {name}?", "content_warning.hide": "Kuzhat an embannadur", "content_warning.show": "Diskwel memes tra", "content_warning.show_more": "Diskouez muioc'h", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index 4a1acfd695..956420026a 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -871,7 +871,6 @@ "status.contains_quote": "Conté una cita", "status.context.loading": "Es carreguen més respostes", "status.context.loading_error": "No s'han pogut carregar respostes noves", - "status.context.loading_more": "Es carreguen més respostes", "status.context.loading_success": "S'han carregat totes les respostes", "status.context.more_replies_found": "S'han trobat més respostes", "status.context.retry": "Torna-ho a provar", diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json index a87c95e8b2..3ba60cb04c 100644 --- a/app/javascript/mastodon/locales/cs.json +++ b/app/javascript/mastodon/locales/cs.json @@ -875,7 +875,6 @@ "status.contains_quote": "Obsahuje citaci", "status.context.loading": "Načítání dalších odpovědí", "status.context.loading_error": "Nelze načíst nové odpovědi", - "status.context.loading_more": "Načítání dalších odpovědí", "status.context.loading_success": "Všechny odpovědi načteny", "status.context.more_replies_found": "Nalezeny další odpovědi", "status.context.retry": "Zkusit znovu", diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json index 843e2ae187..5378330c3a 100644 --- a/app/javascript/mastodon/locales/cy.json +++ b/app/javascript/mastodon/locales/cy.json @@ -870,7 +870,6 @@ "status.contains_quote": "Yn cynnwys dyfyniad", "status.context.loading": "Yn llwytho mwy o atebion", "status.context.loading_error": "Wedi methu llwytho atebion newydd", - "status.context.loading_more": "Yn llwytho mwy o atebion", "status.context.loading_success": "Wedi llwytho'r holl atebion", "status.context.more_replies_found": "Mwy o atebion wedi'u canfod", "status.context.retry": "Ceisio eto", diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index 5c4dc9ff1d..255b41e871 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -875,7 +875,6 @@ "status.contains_quote": "Indeholder citat", "status.context.loading": "Indlæser flere svar", "status.context.loading_error": "Kunne ikke indlæse nye svar", - "status.context.loading_more": "Indlæser flere svar", "status.context.loading_success": "Alle svar indlæst", "status.context.more_replies_found": "Flere svar fundet", "status.context.retry": "Prøv igen", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 2ee15010cc..0cc8b5b2f4 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -875,7 +875,6 @@ "status.contains_quote": "Enthält Zitat", "status.context.loading": "Weitere Antworten laden", "status.context.loading_error": "Weitere Antworten konnten nicht geladen werden", - "status.context.loading_more": "Weitere Antworten laden", "status.context.loading_success": "Alle weiteren Antworten geladen", "status.context.more_replies_found": "Weitere Antworten verfügbar", "status.context.retry": "Erneut versuchen", diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 6df9c3c1e2..5c94e6d858 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -875,7 +875,6 @@ "status.contains_quote": "Περιέχει παράθεση", "status.context.loading": "Φόρτωση περισσότερων απαντήσεων", "status.context.loading_error": "Αδυναμία φόρτωσης νέων απαντήσεων", - "status.context.loading_more": "Φόρτωση περισσότερων απαντήσεων", "status.context.loading_success": "Όλες οι απαντήσεις φορτώθηκαν", "status.context.more_replies_found": "Βρέθηκαν περισσότερες απαντήσεις", "status.context.retry": "Επανάληψη", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index a5049fa803..cb6ba17612 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -875,7 +875,6 @@ "status.contains_quote": "Contiene cita", "status.context.loading": "Cargando más respuestas", "status.context.loading_error": "No se pudieron cargar nuevas respuestas", - "status.context.loading_more": "Cargando más respuestas", "status.context.loading_success": "Se cargaron todas las respuestas", "status.context.more_replies_found": "Se encontraron más respuestas", "status.context.retry": "Reintentar", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 3875b0187d..8c00edc992 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -875,7 +875,6 @@ "status.contains_quote": "Contiene cita", "status.context.loading": "Cargando más respuestas", "status.context.loading_error": "No se pudieron cargar nuevas respuestas", - "status.context.loading_more": "Cargando más respuestas", "status.context.loading_success": "Todas las respuestas cargadas", "status.context.more_replies_found": "Se han encontrado más respuestas", "status.context.retry": "Reintentar", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index 3123f55b85..fc86d60848 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -875,7 +875,6 @@ "status.contains_quote": "Contiene cita", "status.context.loading": "Cargando más respuestas", "status.context.loading_error": "No se pudieron cargar nuevas respuestas", - "status.context.loading_more": "Cargando más respuestas", "status.context.loading_success": "Se cargaron todas las respuestas", "status.context.more_replies_found": "Se encontraron más respuestas", "status.context.retry": "Reintentar", diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json index 33ce2563a7..4848c4f7e1 100644 --- a/app/javascript/mastodon/locales/et.json +++ b/app/javascript/mastodon/locales/et.json @@ -875,7 +875,6 @@ "status.contains_quote": "Sisaldab tsitaati", "status.context.loading": "Laadin veel vastuseid", "status.context.loading_error": "Uute vastuste laadimine ei õnnestunud", - "status.context.loading_more": "Laadin veel vastuseid", "status.context.loading_success": "Kõik vastused on laaditud", "status.context.more_replies_found": "Leidub veel vastuseid", "status.context.retry": "Proovi uuesti", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 27b5c209e6..4cdcece7fe 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -875,7 +875,6 @@ "status.contains_quote": "Sisältää lainauksen", "status.context.loading": "Ladataan lisää vastauksia", "status.context.loading_error": "Ei voitu ladata lisää vastauksia", - "status.context.loading_more": "Ladataan lisää vastauksia", "status.context.loading_success": "Kaikki vastaukset ladattu", "status.context.more_replies_found": "Löytyi lisää vastauksia", "status.context.retry": "Yritä uudelleen", diff --git a/app/javascript/mastodon/locales/fo.json b/app/javascript/mastodon/locales/fo.json index 0863717ab3..a83cd42763 100644 --- a/app/javascript/mastodon/locales/fo.json +++ b/app/javascript/mastodon/locales/fo.json @@ -875,7 +875,6 @@ "status.contains_quote": "Inniheldur sitat", "status.context.loading": "Tekur fleiri svar niður", "status.context.loading_error": "Fekk ikki tikið nýggj svar niður", - "status.context.loading_more": "Tekur fleiri svar niður", "status.context.loading_success": "Øll svar tikin niður", "status.context.more_replies_found": "Fleiri svar funnin", "status.context.retry": "Royn aftur", diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json index 63e8fdf2ab..d5c282d2f8 100644 --- a/app/javascript/mastodon/locales/fr-CA.json +++ b/app/javascript/mastodon/locales/fr-CA.json @@ -251,7 +251,12 @@ "confirmations.revoke_quote.confirm": "Retirer la publication", "confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.", "confirmations.revoke_quote.title": "Retirer la publication ?", + "confirmations.unblock.confirm": "Débloquer", + "confirmations.unblock.title": "Débloquer {name} ?", "confirmations.unfollow.confirm": "Ne plus suivre", + "confirmations.unfollow.title": "Ne plus suivre {name} ?", + "confirmations.withdraw_request.confirm": "Rejeter la demande", + "confirmations.withdraw_request.title": "Rejeter la demande de suivre {name} ?", "content_warning.hide": "Masquer le message", "content_warning.show": "Montrer quand même", "content_warning.show_more": "Montrer plus", @@ -861,6 +866,13 @@ "status.cancel_reblog_private": "Débooster", "status.cannot_quote": "Vous n'êtes pas autorisé à citer ce message", "status.cannot_reblog": "Cette publication ne peut pas être boostée", + "status.contains_quote": "Contient la citation", + "status.context.loading": "Chargement de réponses supplémentaires", + "status.context.loading_error": "Impossible de charger les nouvelles réponses", + "status.context.loading_success": "Toutes les réponses sont chargées", + "status.context.more_replies_found": "Plus de réponses trouvées", + "status.context.retry": "Réessayer", + "status.context.show": "Montrer", "status.continued_thread": "Suite du fil", "status.copy": "Copier un lien vers cette publication", "status.delete": "Supprimer", @@ -890,17 +902,22 @@ "status.quote": "Citer", "status.quote.cancel": "Annuler la citation", "status.quote_error.filtered": "Caché en raison de l'un de vos filtres", + "status.quote_error.limited_account_hint.action": "Afficher quand même", + "status.quote_error.limited_account_hint.title": "Ce profil a été masqué par la modération de {domain}.", "status.quote_error.not_available": "Publication non disponible", "status.quote_error.pending_approval": "Publication en attente", "status.quote_error.pending_approval_popout.body": "Sur Mastodon, vous pouvez contrôler si quelqu'un peut vous citer. Ce message est en attente pendant que nous recevons l'approbation de l'auteur original.", "status.quote_error.revoked": "Post supprimé par l'auteur", "status.quote_followers_only": "Seul·e·s les abonné·e·s peuvent citer cette publication", "status.quote_manual_review": "L'auteur va vérifier manuellement", + "status.quote_noun": "Citation", "status.quote_policy_change": "Changer qui peut vous citer", "status.quote_post_author": "A cité un message par @{name}", "status.quote_private": "Les publications privées ne peuvent pas être citées", "status.quotes": " {count, plural, one {quote} other {quotes}}", "status.quotes.empty": "Personne n'a encore cité ce message. Quand quelqu'un le fera, il apparaîtra ici.", + "status.quotes.local_other_disclaimer": "Les citations rejetées par l'auteur ne seront pas affichées.", + "status.quotes.remote_other_disclaimer": "Seules les citations de {domain} sont garanties d'être affichées ici. Les citations rejetées par l'auteur ne seront pas affichées.", "status.read_more": "En savoir plus", "status.reblog": "Booster", "status.reblog_or_quote": "Boost ou citation", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 5e57d22e14..cc70dbc68f 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -251,7 +251,12 @@ "confirmations.revoke_quote.confirm": "Retirer la publication", "confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.", "confirmations.revoke_quote.title": "Retirer la publication ?", + "confirmations.unblock.confirm": "Débloquer", + "confirmations.unblock.title": "Débloquer {name} ?", "confirmations.unfollow.confirm": "Ne plus suivre", + "confirmations.unfollow.title": "Ne plus suivre {name} ?", + "confirmations.withdraw_request.confirm": "Rejeter la demande", + "confirmations.withdraw_request.title": "Rejeter la demande de suivre {name} ?", "content_warning.hide": "Masquer le message", "content_warning.show": "Montrer quand même", "content_warning.show_more": "Montrer plus", @@ -861,6 +866,13 @@ "status.cancel_reblog_private": "Annuler le partage", "status.cannot_quote": "Vous n'êtes pas autorisé à citer ce message", "status.cannot_reblog": "Ce message ne peut pas être partagé", + "status.contains_quote": "Contient la citation", + "status.context.loading": "Chargement de réponses supplémentaires", + "status.context.loading_error": "Impossible de charger les nouvelles réponses", + "status.context.loading_success": "Toutes les réponses sont chargées", + "status.context.more_replies_found": "Plus de réponses trouvées", + "status.context.retry": "Réessayer", + "status.context.show": "Montrer", "status.continued_thread": "Suite du fil", "status.copy": "Copier le lien vers le message", "status.delete": "Supprimer", @@ -890,17 +902,22 @@ "status.quote": "Citer", "status.quote.cancel": "Annuler la citation", "status.quote_error.filtered": "Caché en raison de l'un de vos filtres", + "status.quote_error.limited_account_hint.action": "Afficher quand même", + "status.quote_error.limited_account_hint.title": "Ce profil a été masqué par la modération de {domain}.", "status.quote_error.not_available": "Publication non disponible", "status.quote_error.pending_approval": "Publication en attente", "status.quote_error.pending_approval_popout.body": "Sur Mastodon, vous pouvez contrôler si quelqu'un peut vous citer. Ce message est en attente pendant que nous recevons l'approbation de l'auteur original.", "status.quote_error.revoked": "Post supprimé par l'auteur", "status.quote_followers_only": "Seul·e·s les abonné·e·s peuvent citer cette publication", "status.quote_manual_review": "L'auteur va vérifier manuellement", + "status.quote_noun": "Citation", "status.quote_policy_change": "Changer qui peut vous citer", "status.quote_post_author": "A cité un message par @{name}", "status.quote_private": "Les publications privées ne peuvent pas être citées", "status.quotes": " {count, plural, one {quote} other {quotes}}", "status.quotes.empty": "Personne n'a encore cité ce message. Quand quelqu'un le fera, il apparaîtra ici.", + "status.quotes.local_other_disclaimer": "Les citations rejetées par l'auteur ne seront pas affichées.", + "status.quotes.remote_other_disclaimer": "Seules les citations de {domain} sont garanties d'être affichées ici. Les citations rejetées par l'auteur ne seront pas affichées.", "status.read_more": "Lire la suite", "status.reblog": "Partager", "status.reblog_or_quote": "Boost ou citation", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index 74021a0c2e..5b0d00d04c 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -875,7 +875,6 @@ "status.contains_quote": "Tá luachan ann", "status.context.loading": "Ag lódáil tuilleadh freagraí", "status.context.loading_error": "Níorbh fhéidir freagraí nua a lódáil", - "status.context.loading_more": "Ag lódáil tuilleadh freagraí", "status.context.loading_success": "Luchtaithe na freagraí uile", "status.context.more_replies_found": "Tuilleadh freagraí aimsithe", "status.context.retry": "Déan iarracht arís", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index a7e1cfd8c3..8ba7a73abe 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -875,7 +875,6 @@ "status.contains_quote": "Contén unha cita", "status.context.loading": "Cargando máis respostas", "status.context.loading_error": "Non se puideron mostrar novas respostas", - "status.context.loading_more": "Cargando máis respostas", "status.context.loading_success": "Móstranse todas as respostas", "status.context.more_replies_found": "Existen máis respostas", "status.context.retry": "Volver tentar", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index f67ee0fd69..61e8cb2e5f 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -875,7 +875,6 @@ "status.contains_quote": "הודעה מכילה ציטוט", "status.context.loading": "נטענות תשובות נוספות", "status.context.loading_error": "טעינת תשובות נוספות נכשלה", - "status.context.loading_more": "נטענות תשובות נוספות", "status.context.loading_success": "כל התשובות נטענו", "status.context.more_replies_found": "תשובות נוספות נמצאו", "status.context.retry": "נסה שוב", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 5b9d6f1530..1d1d824a45 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -875,7 +875,6 @@ "status.contains_quote": "Idézést tartalmaz", "status.context.loading": "Több válasz betöltése", "status.context.loading_error": "Az új válaszok nem tölthetőek be", - "status.context.loading_more": "Több válasz betöltése", "status.context.loading_success": "Összes válasz betöltve", "status.context.more_replies_found": "Több válasz található", "status.context.retry": "Újra", diff --git a/app/javascript/mastodon/locales/ia.json b/app/javascript/mastodon/locales/ia.json index 50b836ad62..4a9929ddc0 100644 --- a/app/javascript/mastodon/locales/ia.json +++ b/app/javascript/mastodon/locales/ia.json @@ -870,7 +870,6 @@ "status.contains_quote": "Contine un citation", "status.context.loading": "Cargante plus responsas", "status.context.loading_error": "Non poteva cargar nove responsas", - "status.context.loading_more": "Cargante plus responsas", "status.context.loading_success": "Tote le responsas cargate", "status.context.more_replies_found": "Plus responsas trovate", "status.context.retry": "Tentar de novo", diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json index 4dbdee7c8c..7934d15692 100644 --- a/app/javascript/mastodon/locales/is.json +++ b/app/javascript/mastodon/locales/is.json @@ -875,7 +875,6 @@ "status.contains_quote": "Inniheldur tilvitnun", "status.context.loading": "Hleð inn fleiri svörum", "status.context.loading_error": "Gat ekki hlaðið inn nýjum svörum", - "status.context.loading_more": "Hleð inn fleiri svörum", "status.context.loading_success": "Öllum svörum hlaðið inn", "status.context.more_replies_found": "Fleiri svör fundust", "status.context.retry": "Reyna aftur", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 204d5d8e30..bd84dfcade 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -875,7 +875,6 @@ "status.contains_quote": "Contiene una citazione", "status.context.loading": "Caricamento di altre risposte", "status.context.loading_error": "Impossibile caricare nuove risposte", - "status.context.loading_more": "Caricamento di altre risposte", "status.context.loading_success": "Tutte le risposte caricate", "status.context.more_replies_found": "Sono state trovate altre risposte", "status.context.retry": "Riprova", diff --git a/app/javascript/mastodon/locales/nan.json b/app/javascript/mastodon/locales/nan.json index 007699f9e4..d2bf7ed8b8 100644 --- a/app/javascript/mastodon/locales/nan.json +++ b/app/javascript/mastodon/locales/nan.json @@ -875,7 +875,6 @@ "status.contains_quote": "包含引用", "status.context.loading": "載入其他回應", "status.context.loading_error": "Bē當載入新回應", - "status.context.loading_more": "載入其他回應", "status.context.loading_success": "回應lóng載入ah", "status.context.more_replies_found": "Tshuē-tio̍h其他回應", "status.context.retry": "Koh試", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 6be053b5cf..ecf8f46f75 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -875,7 +875,6 @@ "status.contains_quote": "Bevat citaat", "status.context.loading": "Meer reacties laden", "status.context.loading_error": "Kon geen nieuwe reacties laden", - "status.context.loading_more": "Meer reacties laden", "status.context.loading_success": "Alle reacties zijn geladen", "status.context.more_replies_found": "Meer reacties gevonden", "status.context.retry": "Opnieuw proberen", diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json index b9ef0e491c..faa4efe61f 100644 --- a/app/javascript/mastodon/locales/nn.json +++ b/app/javascript/mastodon/locales/nn.json @@ -875,7 +875,6 @@ "status.contains_quote": "Inneheld eit sitat", "status.context.loading": "Lastar fleire svar", "status.context.loading_error": "Kunne ikkje lasta nye svar", - "status.context.loading_more": "Lastar fleire svar", "status.context.loading_success": "Alle svara er lasta", "status.context.more_replies_found": "Fann fleire svar", "status.context.retry": "Prøv om att", diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json index 751db6fdd4..82fa079e9b 100644 --- a/app/javascript/mastodon/locales/pt-PT.json +++ b/app/javascript/mastodon/locales/pt-PT.json @@ -870,7 +870,6 @@ "status.contains_quote": "Contém citação", "status.context.loading": "A carregar mais respostas", "status.context.loading_error": "Não foi possível carregar novas respostas", - "status.context.loading_more": "A carregar mais respostas", "status.context.loading_success": "Todas as respostas carregadas", "status.context.more_replies_found": "Foram encontradas mais respostas", "status.context.retry": "Repetir", diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 3f61ff0a29..059a97b0f3 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -28,6 +28,7 @@ "account.disable_notifications": "Resht së njoftuari mua, kur poston @{name}", "account.domain_blocking": "Bllokim përkatësie", "account.edit_profile": "Përpunoni profilin", + "account.edit_profile_short": "Përpunojeni", "account.enable_notifications": "Njoftomë, kur poston @{name}", "account.endorse": "Pasqyrojeni në profil", "account.familiar_followers_many": "Ndjekur nga {name1}, {name2} dhe {othersCount, plural, one {një tjetër që njihni} other {# të tjerë që njihni}}", @@ -40,6 +41,11 @@ "account.featured_tags.last_status_never": "Pa postime", "account.follow": "Ndiqeni", "account.follow_back": "Ndiqe gjithashtu", + "account.follow_back_short": "Ndiqe gjithashtu", + "account.follow_request": "Kërkoni ta ndiqni", + "account.follow_request_cancel": "Anuloje kërkesën", + "account.follow_request_cancel_short": "Anuloje", + "account.follow_request_short": "Kërkoje", "account.followers": "Ndjekës", "account.followers.empty": "Këtë përdorues ende s’e ndjek kush.", "account.followers_counter": "{count, plural, one {{counter} ndjekës} other {{counter} ndjekës}}", @@ -233,13 +239,25 @@ "confirmations.missing_alt_text.secondary": "Postoje, sido qoftë", "confirmations.missing_alt_text.title": "Të shtohet tekst alternativ?", "confirmations.mute.confirm": "Heshtoje", + "confirmations.quiet_post_quote_info.dismiss": "Mos ma kujto më", + "confirmations.quiet_post_quote_info.got_it": "E mora vesh", + "confirmations.quiet_post_quote_info.message": "Kur citoni një postim publik të heshtuar, postimi juaj do të kalohet i fshehur te rrjedha kohore e gjërave në modë.", + "confirmations.quiet_post_quote_info.title": "Citim postimesh publikë të heshtuar", "confirmations.redraft.confirm": "Fshijeni & rihartojeni", "confirmations.redraft.message": "Jeni i sigurt se doni të fshihet kjo gjendje dhe të rihartohet? Të parapëlqyerit dhe përforcimet do të humbin, ndërsa përgjigjet te postimi origjinal do të bëhen jetime.", "confirmations.redraft.title": "Të fshihet & riharothet postimi?", "confirmations.remove_from_followers.confirm": "Hiqe ndjekësin", "confirmations.remove_from_followers.message": "{name} do të reshtë së ndjekuri ju. Jeni i sigurt se doni të vazhdohet?", "confirmations.remove_from_followers.title": "Të hiqet ndjekësi?", + "confirmations.revoke_quote.confirm": "Hiqe postimin", + "confirmations.revoke_quote.message": "Ky veprim s’mund të zhbëhet.", + "confirmations.revoke_quote.title": "Të hiqet postimi?", + "confirmations.unblock.confirm": "Zhbllokoje", + "confirmations.unblock.title": "Të zhbllojohet {name}?", "confirmations.unfollow.confirm": "Resht së ndjekuri", + "confirmations.unfollow.title": "Të ndalet ndjekja për {name}?", + "confirmations.withdraw_request.confirm": "Tërhiqeni mbrapsht kërkesën", + "confirmations.withdraw_request.title": "Të tërhiqet mbrapsht kërkesa për ndjeken e {name}?", "content_warning.hide": "Fshihe postimin", "content_warning.show": "Shfaqe, sido qoftë", "content_warning.show_more": "Shfaq më tepër", @@ -437,10 +455,12 @@ "ignore_notifications_modal.private_mentions_title": "Të shpërfillen njoftime nga Përmendje Private të pakërkuara?", "info_button.label": "Ndihmë", "info_button.what_is_alt_text": "

Ç’është teksti alternativ?

Teksti alternativ jep përshkrime figurash për persona me mangësi në të parët, lidhje me gjerësi bande të ulët, ose për ata që duan kontekst shtesë.

Mund të përmirësoni përdorimin nga persona me aftësi të kufizuara dhe kuptimin për këto, duke shkruar tekst alternativ të qartë, konciz dhe objektiv.

  • Rrokni elementët e rëndësishëm
  • Përmblidhni tekst në figura
  • Përdorni strukturë të rregullt fjalish
  • Shmangni përsëritje informacioni
  • Në aspekte pamore të ndërlikuara (fjala vjen, diagrame ose harta) përqendrohuni te prirje dhe gjetje gjërash kyçe
", + "interaction_modal.action": "Që të ndërveproni me postimin nga {name}, lypset të bëni hyrjen në llogarinë tuaj, ose në çfarëdo shërbyesi Mastodon që përdorni.", "interaction_modal.go": "Shko", "interaction_modal.no_account_yet": "S’keni ende një llogari?", "interaction_modal.on_another_server": "Në një tjetër shërbyes", "interaction_modal.on_this_server": "Në këtë shërbyes", + "interaction_modal.title": "Që të vazhdohet, bëni hyrjen", "interaction_modal.username_prompt": "P.sh., {example}", "intervals.full.days": "{number, plural, one {# ditë} other {# ditë}}", "intervals.full.hours": "{number, plural, one {# orë} other {# orë}}", @@ -461,6 +481,7 @@ "keyboard_shortcuts.home": "Për hapje rrjedhe kohore vetjake", "keyboard_shortcuts.hotkey": "Tast përkatës", "keyboard_shortcuts.legend": "Për shfaqje të kësaj legjende", + "keyboard_shortcuts.load_more": "Kaloje fokusin te butoni “Ngarko më tepër”", "keyboard_shortcuts.local": "Për hapje rrjedhe kohore vendore", "keyboard_shortcuts.mention": "Për përmendje të autorit", "keyboard_shortcuts.muted": "Për hapje liste përdoruesish të heshtuar", @@ -469,6 +490,7 @@ "keyboard_shortcuts.open_media": "Për hapje mediash", "keyboard_shortcuts.pinned": "Për hapje liste mesazhesh të fiksuar", "keyboard_shortcuts.profile": "Për hapje të profilit të autorit", + "keyboard_shortcuts.quote": "Citoni postim", "keyboard_shortcuts.reply": "Për t’iu përgjigjur një postimi", "keyboard_shortcuts.requests": "Për hapje liste kërkesash për ndjekje", "keyboard_shortcuts.search": "Për kalim fokusi te kërkimi", @@ -480,6 +502,8 @@ "keyboard_shortcuts.translate": "për të përkthyer një postim", "keyboard_shortcuts.unfocus": "Për heqjen e fokusit nga fusha e hartimit të mesazheve apo kërkimeve", "keyboard_shortcuts.up": "Për ngjitje sipër nëpër listë", + "learn_more_link.got_it": "E mora vesh", + "learn_more_link.learn_more": "Mësoni më tepër", "lightbox.close": "Mbylle", "lightbox.next": "Pasuesja", "lightbox.previous": "E mëparshmja", @@ -578,6 +602,7 @@ "notification.label.mention": "Përmendje", "notification.label.private_mention": "Përmendje private", "notification.label.private_reply": "Përgjigje private", + "notification.label.quote": "{name} citoi postimin tuaj", "notification.label.reply": "Përgjigje", "notification.mention": "Përmendje", "notification.mentioned_you": "{name} ju ka përmendur", @@ -592,6 +617,7 @@ "notification.moderation_warning.action_suspend": "Llogaria juaj është pezulluar.", "notification.own_poll": "Pyetësori juaj ka përfunduar", "notification.poll": "Ka përfunduar një pyetësor në të cilin keni marrë pjesë", + "notification.quoted_update": "{name} përpunoi një postim që keni cituar", "notification.reblog": "{name} përforcoi mesazhin tuaj", "notification.reblog.name_and_others_with_link": "Ju ka përforcuar {name} dhe {count, plural, one {# tjetër} other {# të tjerë}}", "notification.relationships_severance_event": "Lidhje të humbura me {name}", @@ -635,6 +661,7 @@ "notifications.column_settings.mention": "Përmendje:", "notifications.column_settings.poll": "Përfundime pyetësori:", "notifications.column_settings.push": "Njoftime Push", + "notifications.column_settings.quote": "Ctime:", "notifications.column_settings.reblog": "Përforcime:", "notifications.column_settings.show": "Shfaqi në shtylla", "notifications.column_settings.sound": "Luaj një tingull", @@ -711,6 +738,7 @@ "privacy.public.long": "Cilido që hyn e del në Mastodon", "privacy.public.short": "Publik", "privacy.unlisted.additional": "Ky sillet saktësisht si publik, vetëm se postimi s’do të shfaqet në prurje të drejtpërdrejta, ose në hashtag-ë, te eksploroni, apo kërkim në Mastodon, edhe kur keni zgjedhur të jetë për tërë llogarinë.", + "privacy.unlisted.long": "Fshehur nga përfundime kërkimi në Mastodon, rrjedha kohore gjërash në modë dhe publike", "privacy.unlisted.short": "Publik i qetë", "privacy_policy.last_updated": "Përditësuar së fundi më {date}", "privacy_policy.title": "Rregulla Privatësie", @@ -729,6 +757,9 @@ "relative_time.minutes": "{number}m", "relative_time.seconds": "{number}s", "relative_time.today": "sot", + "remove_quote_hint.button_label": "E mora vesh", + "remove_quote_hint.message": "Këtë mund ta bëni një menuja e mundësive {icon}.", + "remove_quote_hint.title": "Doni të hiqet postimi juaj i cituar?", "reply_indicator.attachments": "{count, plural, one {# bashkëngjitje} other {# bashkëngjitje}}", "reply_indicator.cancel": "Anuloje", "reply_indicator.poll": "Pyetësor", @@ -823,10 +854,19 @@ "status.block": "Blloko @{name}", "status.bookmark": "Faqeruaje", "status.cancel_reblog_private": "Shpërforcojeni", + "status.cannot_quote": "S’keni leje të citoni këtë postim", "status.cannot_reblog": "Ky postim s’mund të përforcohet", + "status.contains_quote": "Përmban citim", + "status.context.loading": "Po ngarkohen më tepër përgjigje", + "status.context.loading_error": "S’u ngarkuan dot përgjigje të reja", + "status.context.loading_success": "Janë ngarkuar krejt përgjigjet", + "status.context.more_replies_found": "U gjetën më tepër përgjigje", + "status.context.retry": "Riprovoni", + "status.context.show": "Shfaqe", "status.continued_thread": "Vazhdoi rrjedhën", "status.copy": "Kopjoje lidhjen për te mesazhi", "status.delete": "Fshije", + "status.delete.success": "Postimi u fshi", "status.detailed_status": "Pamje e hollësishme bisede", "status.direct": "Përmendje private për @{name}", "status.direct_indicator": "Përmendje private", @@ -850,19 +890,38 @@ "status.open": "Zgjeroje këtë mesazh", "status.pin": "Fiksoje në profil", "status.quote_error.filtered": "Fshehur për shkak të njërit nga filtrat tuaj", + "status.quote_error.limited_account_hint.action": "Shfaqe, sido qoftë", + "status.quote_error.limited_account_hint.title": "Kjo llogari është fshehur nga moderatorët e {domain}.", + "status.quote_error.not_available": "Postim që s’mund të kihet", + "status.quote_error.pending_approval": "Postim pezull", + "status.quote_error.pending_approval_popout.body": "Në Mastodon mundeni të kontrolloni nëse dikush ju citon a jo. Ky postim është pezull, teksa po marrim miratimin e autorit origjinal.", + "status.quote_error.revoked": "Postim i hequr nga autori", + "status.quote_followers_only": "Këtë postim mund ta citojnë vetëm ndjekës", + "status.quote_manual_review": "Autori do ta shqyrtojë dorazi", + "status.quote_noun": "Citim", + "status.quote_post_author": "U citua një postim nga @{name}", + "status.quotes": "{count, plural, one {citim} other {citime}}", + "status.quotes.empty": "Këtë postim ende s’e ka cituar kush. Kur dikush ta bëjë, do të shfaqet këtu.", + "status.quotes.local_other_disclaimer": "Citimet e hedhura poshtë nga autori s’do të shfaqen.", + "status.quotes.remote_other_disclaimer": "Këtu garantohet të shfaqen vetëm citime nga {domain}. Citime të hedhura poshtë nga autori s’do të shfaqen.", "status.read_more": "Lexoni më tepër", "status.reblog": "Përforcojeni", + "status.reblog_or_quote": "Përforconi ose citoni", + "status.reblog_private": "Rindajeni me ndjekësit tuaj", "status.reblogged_by": "{name} përforcoi", "status.reblogs": "{count, plural, one {përforcim} other {përforcime}}", "status.reblogs.empty": "Këtë mesazh s’e ka përforcuar njeri deri tani. Kur ta bëjë dikush, kjo do të duket këtu.", "status.redraft": "Fshijeni & rihartojeni", "status.remove_bookmark": "Hiqe faqerojtësin", "status.remove_favourite": "Hiqe nga të parapëlqyerat", + "status.remove_quote": "Hiqe", "status.replied_in_thread": "U përgjigj te rrjedha", "status.replied_to": "Iu përgjigj {name}", "status.reply": "Përgjigjuni", "status.replyAll": "Përgjigjuni rrjedhës", "status.report": "Raportojeni @{name}", + "status.request_quote": "Kërkoni të citohet", + "status.revoke_quote": "Hiqe postimin tim nga postimi i @{name}", "status.sensitive_warning": "Lëndë rezervat", "status.share": "Ndajeni me të tjerë", "status.show_less_all": "Shfaq më pak për të tërë", @@ -922,5 +981,14 @@ "video.skip_forward": "Anashkalo pasardhësen", "video.unmute": "Hiqi heshtimin", "video.volume_down": "Ulje volumi", - "video.volume_up": "Ngritje volumi" + "video.volume_up": "Ngritje volumi", + "visibility_modal.helper.direct_quoting": "Përmendje private të krijuara në Mastodon s’mund të citohen nga të tjerë.", + "visibility_modal.helper.privacy_editing": "Dukshmëria s’mund të ndryshohet pasi postimi botohet.", + "visibility_modal.helper.privacy_private_self_quote": "Citimet nga ju vetë të postime private s’mund të bëhen publike.", + "visibility_modal.helper.private_quoting": "Postime vetëm për ndjekësit, të krijuara në Mastodon s’mund të citohen nga të tjerë.", + "visibility_modal.instructions": "Kontrolloni cilët mund të ndërveprojnë me këtë postim. Rregullime mund të aplikooni edhe mbi krejt postimet e ardshme, që nga Parapëlqime > Parazgjedhje postimi.", + "visibility_modal.privacy_label": "Dukshmëri", + "visibility_modal.quote_label": "Cilët mund të citojnë", + "visibility_modal.quote_nobody": "Thjesht unë", + "visibility_modal.save": "Ruaje" } diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 96fa8ae986..6301c8b7da 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -875,7 +875,6 @@ "status.contains_quote": "Alıntı içeriyor", "status.context.loading": "Daha fazla yanıt yükleniyor", "status.context.loading_error": "Yeni yanıtlar yüklenemiyor", - "status.context.loading_more": "Daha fazla yanıt yükleniyor", "status.context.loading_success": "Tüm yanıtlar yüklendi", "status.context.more_replies_found": "Daha fazla yanıt bulundu", "status.context.retry": "Yeniden dene", @@ -923,6 +922,8 @@ "status.quote_private": "Özel gönderiler alıntılanamaz", "status.quotes": "{count, plural, one {# alıntı} other {# alıntı}}", "status.quotes.empty": "Henüz hiç kimse bu gönderiyi alıntılamadı. Herhangi bir kullanıcı alıntıladığında burada görüntülenecek.", + "status.quotes.local_other_disclaimer": "Yazar tarafından reddedilen alıntılar gösterilmez.", + "status.quotes.remote_other_disclaimer": "Yalnızca {domain} adresinden gelen alıntılar burada gösterilir. Yazar tarafından reddedilen alıntılar gösterilmez.", "status.read_more": "Devamını okuyun", "status.reblog": "Yeniden paylaş", "status.reblog_or_quote": "Yükselt veya alıntıla", diff --git a/app/javascript/mastodon/locales/vi.json b/app/javascript/mastodon/locales/vi.json index 08b224e3d6..61de7d3e56 100644 --- a/app/javascript/mastodon/locales/vi.json +++ b/app/javascript/mastodon/locales/vi.json @@ -875,7 +875,6 @@ "status.contains_quote": "Chứa trích dẫn", "status.context.loading": "Tải thêm các trả lời", "status.context.loading_error": "Không thể tải những trả lời mới", - "status.context.loading_more": "Tải thêm các trả lời", "status.context.loading_success": "Đã tải toàn bộ trả lời", "status.context.more_replies_found": "Có trả lời mới", "status.context.retry": "Thử lại", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 1397f28f93..87fe47d777 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -875,7 +875,6 @@ "status.contains_quote": "包含引用", "status.context.loading": "正在加载更多回复", "status.context.loading_error": "无法加载新回复", - "status.context.loading_more": "正在加载更多回复", "status.context.loading_success": "已加载所有回复", "status.context.more_replies_found": "已找到更多回复", "status.context.retry": "重试", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 5e34081094..f5e285fbeb 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -875,7 +875,6 @@ "status.contains_quote": "包含引用嘟文", "status.context.loading": "讀取更多回嘟", "status.context.loading_error": "無法讀取新回嘟", - "status.context.loading_more": "讀取更多回嘟", "status.context.loading_success": "已讀取所有回嘟", "status.context.more_replies_found": "已有更多回嘟", "status.context.retry": "再試一次", diff --git a/config/locales/be.yml b/config/locales/be.yml index cd7bf94e1a..20defa85d2 100644 --- a/config/locales/be.yml +++ b/config/locales/be.yml @@ -876,6 +876,10 @@ be: all: Для ўсіх disabled: Нікому users: Лакальным карыстальнікам, якія ўвайшлі + feed_access: + modes: + authenticated: Толькі аўтэнтыфікаваныя карыстальнікі + public: Усе registrations: moderation_recommandation: Пераканайцеся, што ў вас ёсць адэкватная і аператыўная каманда мадэратараў, перш чым адчыняць рэгістрацыю для ўсіх жадаючых! preamble: Кантралюйце, хто можа ствараць уліковы запіс на вашым серверы. diff --git a/config/locales/br.yml b/config/locales/br.yml index c6cb915252..eef6f11828 100644 --- a/config/locales/br.yml +++ b/config/locales/br.yml @@ -145,6 +145,7 @@ br: destroy_custom_emoji_html: Dilamet eo bet ar fromlun %{target} gant %{name} destroy_email_domain_block_html: Distanket eo bet an domani postel %{target} gant %{name} destroy_status_html: Dilamet eo bet toud %{target} gant %{name} + destroy_user_role_html: Dilamet eo bet ar perzh %{target} gant %{name} disable_custom_emoji_html: Diweredekaet eo bet ar fromlun %{target} gant %{name} enable_custom_emoji_html: Gweredekaet eo bet ar fromlun %{target} gant %{name} resend_user_html: Adkaset eo bet ar postel kadarnaat evit %{target} gant %{name} diff --git a/config/locales/cs.yml b/config/locales/cs.yml index fd3b579781..8b21cfb2eb 100644 --- a/config/locales/cs.yml +++ b/config/locales/cs.yml @@ -876,6 +876,10 @@ cs: all: Všem disabled: Nikomu users: Přihlášeným místním uživatelům + feed_access: + modes: + authenticated: Pouze autentifikovaní uživatelé + public: Všichni registrations: moderation_recommandation: Před otevřením registrací všem se ujistěte, že máte vhodný a reaktivní moderační tým! preamble: Mějte pod kontrolou, kdo může vytvořit účet na vašem serveru. diff --git a/config/locales/da.yml b/config/locales/da.yml index 8ad3ccb138..377ac4952d 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -848,6 +848,10 @@ da: all: Til alle disabled: Til ingen users: Til indloggede lokale brugere + feed_access: + modes: + authenticated: Kun godkendte brugere + public: Alle registrations: moderation_recommandation: Sørg for, at der er et tilstrækkeligt og reaktivt moderationsteam, før registrering åbnes for alle! preamble: Styr, hvem der kan oprette en konto på serveren. @@ -1281,7 +1285,7 @@ da: account_status: Kontostatus confirming: Afventer færdiggørelse af e-mailbekræftelse. functional: Din konto er fuldt funktionel. - pending: Ansøgningen afventer gennemgang af vores personale. Dette kan tage noget tid. Man bør modtage en e-mail, såfremt ansøgningen godkendes. + pending: Ansøgningen afventer gennemgang af vores personale. Dette kan tage noget tid. Du modtager en e-mail, hvis din ansøgning bliver godkendt. redirecting_to: Din konto er inaktiv, da den pt. er omdirigerer til %{acct}. self_destruct: Da %{domain} er under nedlukning, vil kontoadgangen være begrænset. view_strikes: Se tidligere anmeldelser af din konto diff --git a/config/locales/de.yml b/config/locales/de.yml index 0f694493fa..90b97335db 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -848,6 +848,10 @@ de: all: Allen disabled: Niemandem users: Für angemeldete lokale Benutzer*innen + feed_access: + modes: + authenticated: Nur authentifizierte Nutzer*innen + public: Alle registrations: moderation_recommandation: Bitte vergewissere dich, dass du ein geeignetes und reaktionsschnelles Moderationsteam hast, bevor du die Registrierungen uneingeschränkt zulässt! preamble: Lege fest, wer auf deinem Server ein Konto erstellen darf. diff --git a/config/locales/el.yml b/config/locales/el.yml index a17e870075..35f5fe2ff2 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -848,6 +848,10 @@ el: all: Για όλους disabled: Για κανέναν users: Προς συνδεδεμένους τοπικούς χρήστες + feed_access: + modes: + authenticated: Πιστοποιημένοι χρήστες μόνο + public: Όλοι registrations: moderation_recommandation: Παρακαλώ βεβαιώσου ότι έχεις μια επαρκής και ενεργή ομάδα συντονισμού πριν ανοίξεις τις εγγραφές για όλους! preamble: Έλεγξε ποιος μπορεί να δημιουργήσει ένα λογαριασμό στον διακομιστή σας. diff --git a/config/locales/es-AR.yml b/config/locales/es-AR.yml index 7e019daf46..8b58313c03 100644 --- a/config/locales/es-AR.yml +++ b/config/locales/es-AR.yml @@ -848,6 +848,10 @@ es-AR: all: A todos disabled: A nadie users: A usuarios locales con sesiones abiertas + feed_access: + modes: + authenticated: Solo usuarios autenticados + public: Todos registrations: moderation_recommandation: Por favor, ¡asegurate de tener un equipo de moderación adecuado y reactivo antes de abrir los registros a todos! preamble: Controlá quién puede crear una cuenta en tu servidor. diff --git a/config/locales/es-MX.yml b/config/locales/es-MX.yml index 2f0a69891f..1dde5faf22 100644 --- a/config/locales/es-MX.yml +++ b/config/locales/es-MX.yml @@ -848,6 +848,10 @@ es-MX: all: A todos disabled: A nadie users: Para los usuarios locales que han iniciado sesión + feed_access: + modes: + authenticated: Solo usuarios autenticados + public: Todos registrations: moderation_recommandation: "¡Por favor, asegúrate de contar con un equipo de moderación adecuado y activo antes de abrir el registro al público!" preamble: Controla quién puede crear una cuenta en tu servidor. diff --git a/config/locales/es.yml b/config/locales/es.yml index a14c924bd0..b5c4eef3d1 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -848,6 +848,10 @@ es: all: A todos disabled: A nadie users: Para los usuarios locales que han iniciado sesión + feed_access: + modes: + authenticated: Solo usuarios autenticados + public: Todos registrations: moderation_recommandation: Por favor, ¡asegúrate de tener un equipo de moderación adecuado y reactivo antes de abrir los registros a todo el mundo! preamble: Controla quién puede crear una cuenta en tu servidor. diff --git a/config/locales/fi.yml b/config/locales/fi.yml index ec474090a6..eb6b143a91 100644 --- a/config/locales/fi.yml +++ b/config/locales/fi.yml @@ -848,6 +848,10 @@ fi: all: Kaikille disabled: Ei kenellekään users: Kirjautuneille paikallisille käyttäjille + feed_access: + modes: + authenticated: Vain todennetut käyttäjät + public: Kaikki registrations: moderation_recommandation: Varmista, että sinulla on riittävä ja toimintavalmis joukko moderaattoreita, ennen kuin avaat rekisteröitymisen kaikille! preamble: Määritä, kuka voi luoda tilin palvelimellesi. diff --git a/config/locales/fo.yml b/config/locales/fo.yml index f6ba039efc..dcddd66899 100644 --- a/config/locales/fo.yml +++ b/config/locales/fo.yml @@ -848,6 +848,10 @@ fo: all: Til øll disabled: Til ongan users: Fyri lokalum brúkarum, sum eru ritaðir inn + feed_access: + modes: + authenticated: Einans váttaðir brúkarar + public: Øll registrations: moderation_recommandation: Vinarliga tryggja tær, at tú hevur eitt nøktandi og klárt umsjónartoymi, áðreen tú letur upp fyri skrásetingum frá øllum! preamble: Stýr, hvør kann stovna eina kontu á tínum ambætara. diff --git a/config/locales/ga.yml b/config/locales/ga.yml index adfdacb034..5790b4c58c 100644 --- a/config/locales/ga.yml +++ b/config/locales/ga.yml @@ -890,6 +890,10 @@ ga: all: Do chách disabled: Do dhuine ar bith users: Chun úsáideoirí áitiúla logáilte isteach + feed_access: + modes: + authenticated: Úsáideoirí fíordheimhnithe amháin + public: Gach duine registrations: moderation_recommandation: Cinntigh le do thoil go bhfuil foireann mhodhnóireachta imoibríoch leordhóthanach agat sula n-osclaíonn tú clárúcháin do gach duine! preamble: Rialú cé atá in ann cuntas a chruthú ar do fhreastalaí. diff --git a/config/locales/gl.yml b/config/locales/gl.yml index 4eee0fe0f9..c6a852fd8e 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -848,6 +848,10 @@ gl: all: Para todos disabled: Para ninguén users: Para usuarias locais conectadas + feed_access: + modes: + authenticated: Só para usuarias con sesión iniciada + public: Para calquera registrations: moderation_recommandation: Por favor, pon interese en crear un equipo de moderación competente e reactivo antes de permitir que calquera poida crear unha conta! preamble: Xestiona quen pode crear unha conta no teu servidor. diff --git a/config/locales/he.yml b/config/locales/he.yml index 9c8d91722a..f577d64366 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -876,6 +876,10 @@ he: all: לכולם disabled: לאף אחד users: למשתמשים מקומיים מחוברים + feed_access: + modes: + authenticated: משתמשים מאומתים בלבד + public: כולם registrations: moderation_recommandation: יש לוודא שלאתר יש צוות מנחות ומנחי שיחה מספק ושירותי בטרם תבחרו לפתוח הרשמה לכולם! preamble: שליטה בהרשאות יצירת חשבון בשרת שלך. diff --git a/config/locales/is.yml b/config/locales/is.yml index 5f783331c0..ba9a7680ac 100644 --- a/config/locales/is.yml +++ b/config/locales/is.yml @@ -850,6 +850,10 @@ is: all: Til allra disabled: Til engra users: Til innskráðra staðværra notenda + feed_access: + modes: + authenticated: Einungis auðkenndir notendur + public: Allir registrations: moderation_recommandation: Tryggðu að þú hafir hæft og aðgengilegt umsjónarteymi til taks áður en þú opnar á skráningar fyrir alla! preamble: Stýrðu því hverjir geta útbúið notandaaðgang á netþjóninum þínum. diff --git a/config/locales/simple_form.cs.yml b/config/locales/simple_form.cs.yml index 0d6cc9989e..93d6b202d8 100644 --- a/config/locales/simple_form.cs.yml +++ b/config/locales/simple_form.cs.yml @@ -285,12 +285,16 @@ cs: content_cache_retention_period: Doba uchovávání vzdáleného obsahu custom_css: Vlastní CSS favicon: Favicon + local_live_feed_access: Přístup k live kanálům s lokálními příspěvky + local_topic_feed_access: Přístup ke kanálům s hashtagy a odkazy s lokálními příspěvky mascot: Vlastní maskot (zastaralé) media_cache_retention_period: Doba uchovávání mezipaměti médií min_age: Minimální věková hranice peers_api_enabled: Zveřejnit seznam nalezených serverů v API profile_directory: Povolit adresář profilů registrations_mode: Kdo se může přihlásit + remote_live_feed_access: Přístup k live kanálům s vzdálenými příspěvky + remote_topic_feed_access: Přístup ke kanálům s hashtagy a odkazy se vzdálenými příspěvky require_invite_text: Požadovat důvod pro připojení show_domain_blocks: Zobrazit blokace domén show_domain_blocks_rationale: Zobrazit proč byly blokovány domény diff --git a/config/locales/simple_form.da.yml b/config/locales/simple_form.da.yml index 7476640b93..7b15510e03 100644 --- a/config/locales/simple_form.da.yml +++ b/config/locales/simple_form.da.yml @@ -283,12 +283,16 @@ da: content_cache_retention_period: Opbevaringsperiode for eksternt indhold custom_css: Tilpasset CSS favicon: Favikon + local_live_feed_access: Adgang til live feeds med lokale indlæg + local_topic_feed_access: Adgang til hashtag- og link-feeds med lokale indlæg mascot: Tilpasset maskot (ældre funktion) media_cache_retention_period: Media-cache opbevaringsperiode min_age: Minimums alderskrav peers_api_enabled: Udgiv liste over fundne server i API'en profile_directory: Aktivér profiloversigt registrations_mode: Hvem, der kan tilmelde sig + remote_live_feed_access: Adgang til live feeds med eksterne indlæg + remote_topic_feed_access: Adgang til hashtag- og link-feeds med eksterne indlæg require_invite_text: Kræv tilmeldingsbegrundelse show_domain_blocks: Vis domæneblokeringer show_domain_blocks_rationale: Vis, hvorfor domæner blev blokeret diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 14f007bccb..fac3892a90 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -283,12 +283,16 @@ de: content_cache_retention_period: Aufbewahrungsfrist für externe Inhalte custom_css: Eigenes CSS favicon: Favicon + local_live_feed_access: Zugriff auf Live-Feeds, die lokale Beiträge beinhalten + local_topic_feed_access: Zugriff auf Hashtags und Links, die lokale Beiträge beinhalten mascot: Benutzerdefiniertes Maskottchen (Legacy) media_cache_retention_period: Aufbewahrungsfrist für Medien im Cache min_age: Erforderliches Mindestalter peers_api_enabled: Die entdeckten Server im Fediverse über die API veröffentlichen profile_directory: Profilverzeichnis aktivieren registrations_mode: Wer darf ein neues Konto registrieren? + remote_live_feed_access: Zugriff auf Live-Feeds, die Beiträge externer Server beinhalten + remote_topic_feed_access: Zugriff auf Hashtags und Links, die Beiträge externer Server beinhalten require_invite_text: Begründung für Beitritt verlangen show_domain_blocks: Anzeigen, welche Domains gesperrt wurden show_domain_blocks_rationale: Anzeigen, weshalb Domains gesperrt wurden diff --git a/config/locales/simple_form.el.yml b/config/locales/simple_form.el.yml index 62942eb2c2..f65ad49793 100644 --- a/config/locales/simple_form.el.yml +++ b/config/locales/simple_form.el.yml @@ -283,12 +283,16 @@ el: content_cache_retention_period: Περίοδος διατήρησης απομακρυσμένου περιεχομένου custom_css: Προσαρμοσμένο CSS favicon: Favicon + local_live_feed_access: Πρόσβαση σε ζωντανές ροές με τοπικές αναρτήσεις + local_topic_feed_access: Πρόσβαση σε ροές ετικετών και συνδέσμων με τοπικές αναρτήσεις mascot: Προσαρμοσμένη μασκότ (απαρχαιωμένο) media_cache_retention_period: Περίοδος διατήρησης προσωρινής μνήμης πολυμέσων min_age: Ελάχιστη απαιτούμενη ηλικία peers_api_enabled: Δημοσίευση λίστας των εντοπισμένων διακομιστών στο API profile_directory: Ενεργοποίηση καταλόγου προφίλ registrations_mode: Ποιος μπορεί να εγγραφεί + remote_live_feed_access: Πρόσβαση σε ζωντανές ροές με απομακρυσμένες αναρτήσεις + remote_topic_feed_access: Πρόσβαση σε ροές ετικετών και συνδέσμων με απομακρυσμένες αναρτήσεις require_invite_text: Απαίτησε έναν λόγο για να γίνει κάποιος μέλος show_domain_blocks: Εμφάνιση αποκλεισμένων τομέων show_domain_blocks_rationale: Εμφάνιση γιατί αποκλείστηκαν οι τομείς diff --git a/config/locales/simple_form.es-AR.yml b/config/locales/simple_form.es-AR.yml index 019b2fbfe2..502a69ada9 100644 --- a/config/locales/simple_form.es-AR.yml +++ b/config/locales/simple_form.es-AR.yml @@ -283,12 +283,16 @@ es-AR: content_cache_retention_period: Período de retención de contenido remoto custom_css: CSS personalizado favicon: Favicón + local_live_feed_access: Acceso a las cronologías que destacan publicaciones locales + local_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones locales mascot: Mascota personalizada (legado) media_cache_retention_period: Período de retención de la caché de medios min_age: Edad mínima requerida peers_api_enabled: Publicar lista de servidores descubiertos en la API profile_directory: Habilitar directorio de perfiles registrations_mode: Quién puede registrarse + remote_live_feed_access: Acceso a las cronologías que destacan publicaciones remotas + remote_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones remotas require_invite_text: Requerir un motivo para unirse show_domain_blocks: Mostrar dominios bloqueados show_domain_blocks_rationale: Mostrar por qué se bloquearon los dominios diff --git a/config/locales/simple_form.es-MX.yml b/config/locales/simple_form.es-MX.yml index 493c4e1fe8..088423f275 100644 --- a/config/locales/simple_form.es-MX.yml +++ b/config/locales/simple_form.es-MX.yml @@ -283,12 +283,16 @@ es-MX: content_cache_retention_period: Periodo de conservación de contenidos remotos custom_css: CSS personalizado favicon: Favicon + local_live_feed_access: Acceso a las cronologías que destacan publicaciones locales + local_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones locales mascot: Mascota personalizada (legado) media_cache_retention_period: Período de retención de caché multimedia min_age: Edad mínima requerida peers_api_enabled: Publicar lista de servidores descubiertos en la API profile_directory: Habilitar directorio de perfiles registrations_mode: Quién puede registrarse + remote_live_feed_access: Acceso a las cronologías que destacan publicaciones remotas + remote_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones remotas require_invite_text: Requerir una razón para unirse show_domain_blocks: Mostrar dominios bloqueados show_domain_blocks_rationale: Mostrar por qué se bloquearon los dominios diff --git a/config/locales/simple_form.es.yml b/config/locales/simple_form.es.yml index 02de69c4ef..d6f1c5d865 100644 --- a/config/locales/simple_form.es.yml +++ b/config/locales/simple_form.es.yml @@ -283,12 +283,16 @@ es: content_cache_retention_period: Período de retención de contenido remoto custom_css: CSS personalizado favicon: Favicon + local_live_feed_access: Acceso a las cronologías que destacan publicaciones locales + local_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones locales mascot: Mascota personalizada (legado) media_cache_retention_period: Período de retención de caché multimedia min_age: Edad mínima requerida peers_api_enabled: Publicar lista de servidores descubiertos en la API profile_directory: Habilitar directorio de perfiles registrations_mode: Quién puede registrarse + remote_live_feed_access: Acceso a las cronologías que destacan publicaciones remotas + remote_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones remotas require_invite_text: Requerir una razón para unirse show_domain_blocks: Mostrar dominios bloqueados show_domain_blocks_rationale: Mostrar por qué se bloquearon los dominios diff --git a/config/locales/simple_form.fi.yml b/config/locales/simple_form.fi.yml index e1783d4517..d99e86feee 100644 --- a/config/locales/simple_form.fi.yml +++ b/config/locales/simple_form.fi.yml @@ -282,12 +282,16 @@ fi: content_cache_retention_period: Etäsisällön säilytysaika custom_css: Mukautettu CSS favicon: Sivustokuvake + local_live_feed_access: Pääsy paikallisia julkaisuja esitteleviin livesyötteisiin + local_topic_feed_access: Pääsy paikallisia julkaisuja esitteleviin aihetunniste- ja linkkisyötteisiin mascot: Mukautettu maskotti (vanhentunut) media_cache_retention_period: Mediasisällön välimuistin säilytysaika min_age: Vähimmäisikävaatimus peers_api_enabled: Julkaise löydettyjen palvelinten luettelo ohjelmointirajapinnassa profile_directory: Ota profiilihakemisto käyttöön registrations_mode: Kuka voi rekisteröityä + remote_live_feed_access: Pääsy etäjulkaisuja esitteleviin livesyötteisiin + remote_topic_feed_access: Pääsy etäjulkaisuja esitteleviin aihetunniste- ja linkkisyötteisiin require_invite_text: Vaadi liittymissyy show_domain_blocks: Näytä verkkotunnusten estot show_domain_blocks_rationale: Näytä, miksi verkkotunnukset on estetty diff --git a/config/locales/simple_form.fo.yml b/config/locales/simple_form.fo.yml index 8ae151c609..605b32fd80 100644 --- a/config/locales/simple_form.fo.yml +++ b/config/locales/simple_form.fo.yml @@ -283,12 +283,16 @@ fo: content_cache_retention_period: Tíðarskeið fyri varðveiðslu av fjartilfari custom_css: Serskilt CSS favicon: Favikon + local_live_feed_access: Atgongd til beinleiðis rásir við lokalum postum + local_topic_feed_access: Atgongd til frámerki og rásir við leinkjum við lokalum postum mascot: Serskildur maskottur (arvur) media_cache_retention_period: Tíðarskeið, har miðlagoymslur verða varðveittar min_age: Aldursmark peers_api_enabled: Kunnger lista við uppdagaðum ambætarum í API'num profile_directory: Ger vangaskrá virkna registrations_mode: Hvør kann tilmelda seg + remote_live_feed_access: Atgongd til beinleiðis rásir við fjarum postum + remote_topic_feed_access: Atgongd til frámerki og rásir við leinkjum við fjarum postum require_invite_text: Krev eina orsøk at luttaka show_domain_blocks: Vís navnaøkisblokeringar show_domain_blocks_rationale: Vís hví navnaøki vóru blokeraði diff --git a/config/locales/simple_form.ga.yml b/config/locales/simple_form.ga.yml index 4c634fb6b3..f9c0676451 100644 --- a/config/locales/simple_form.ga.yml +++ b/config/locales/simple_form.ga.yml @@ -286,12 +286,16 @@ ga: content_cache_retention_period: Tréimhse choinneála inneachair cianda custom_css: CSS saincheaptha favicon: Favicon + local_live_feed_access: Rochtain ar bheatha bheo ina bhfuil poist áitiúla + local_topic_feed_access: Rochtain ar fhothaí hashtag agus nasc ina bhfuil poist áitiúla mascot: Mascóg saincheaptha (oidhreacht) media_cache_retention_period: Tréimhse choinneála taisce meán min_age: Riachtanas aoise íosta peers_api_enabled: Foilsigh liosta de na freastalaithe aimsithe san API profile_directory: Cumasaigh eolaire próifíle registrations_mode: Cé atá in ann clárú + remote_live_feed_access: Rochtain ar bheatha bheo ina bhfuil poist iargúlta + remote_topic_feed_access: Rochtain ar fhothaí hashtag agus nasc ina bhfuil poist iargúlta require_invite_text: A cheangal ar chúis a bheith páirteach show_domain_blocks: Taispeáin bloic fearainn show_domain_blocks_rationale: Taispeáin cén fáth ar cuireadh bac ar fhearann diff --git a/config/locales/simple_form.gl.yml b/config/locales/simple_form.gl.yml index 8898299984..7c77d4aec9 100644 --- a/config/locales/simple_form.gl.yml +++ b/config/locales/simple_form.gl.yml @@ -283,12 +283,16 @@ gl: content_cache_retention_period: Período de retención de contido remoto custom_css: CSS personalizado favicon: Favicon + local_live_feed_access: Acceso a cronoloxías ao vivo que mostran publicacións locais + local_topic_feed_access: Acceso a cronoloxías de ligazóns e cancelos que mostran publicacións locais mascot: Mascota propia (herdado) media_cache_retention_period: Período de retención da caché multimedia min_age: Idade mínima requerida peers_api_enabled: Publicar na API unha lista dos servidores descubertos profile_directory: Activar o directorio de perfís registrations_mode: Quen se pode rexistrar + remote_live_feed_access: Acceso a cronoloxías ao vivo que mostran publicacións remotas + remote_topic_feed_access: Acceso a cronoloxías de ligazóns e cancelos que mostran publicacións remotas require_invite_text: Pedir unha razón para unirse show_domain_blocks: Amosar dominios bloqueados show_domain_blocks_rationale: Explicar porque están bloqueados os dominios diff --git a/config/locales/simple_form.he.yml b/config/locales/simple_form.he.yml index 14aac714b6..53ba7c1e5e 100644 --- a/config/locales/simple_form.he.yml +++ b/config/locales/simple_form.he.yml @@ -285,12 +285,16 @@ he: content_cache_retention_period: תקופת השמירה על תוכן חיצוני custom_css: CSS בהתאמה אישית favicon: סמל מועדפים (Favicon) + local_live_feed_access: גישה לפידים חיים המציגים הודעות מקומיות + local_topic_feed_access: גישה לפידים של תגיות וקישורים המציגים הודעות מקומיות mascot: סמל השרת (ישן) media_cache_retention_period: תקופת שמירת מטמון מדיה min_age: דרישת גיל מינימלי peers_api_enabled: פרסם רשימה של שרתים שנתגלו באמצעות ה-API profile_directory: הפעלת ספריית פרופילים registrations_mode: מי יכולים לפתוח חשבון + remote_live_feed_access: גישה לפידים חיים המציגים הודעות מהעולם + remote_topic_feed_access: גישה לפידים של תגיות וקישורים המציגים הודעות מהעולם require_invite_text: לדרוש סיבה להצטרפות show_domain_blocks: הצגת חסימת דומיינים show_domain_blocks_rationale: הצגת סיבות חסימה למתחמים diff --git a/config/locales/simple_form.is.yml b/config/locales/simple_form.is.yml index 3c74ba76c2..03d093fc6b 100644 --- a/config/locales/simple_form.is.yml +++ b/config/locales/simple_form.is.yml @@ -283,12 +283,16 @@ is: content_cache_retention_period: Tímabil sem á að geyma fjartengt efni custom_css: Sérsniðið CSS favicon: Auðkennismynd + local_live_feed_access: Aðgangur að beinum streymum, þar með töldum staðværum færslum + local_topic_feed_access: Aðgangur að myllumerkjum og tengdum streymum, þar með töldum staðværum færslum mascot: Sérsniðið gæludýr (eldra) media_cache_retention_period: Tímalengd sem myndefni haldið min_age: Kröfur um lágmarksaldur peers_api_enabled: Birta lista yfir uppgötvaða netþjóna í API-kerfisviðmótinu profile_directory: Virkja notendamöppu registrations_mode: Hverjir geta nýskráð sig + remote_live_feed_access: Aðgangur að beinum streymum, þar með töldum fjartengdum færslum + remote_topic_feed_access: Aðgangur að myllumerkjum og tengdum streymum, þar með töldum fjartengdum færslum require_invite_text: Krefjast ástæðu fyrir þátttöku show_domain_blocks: Sýna útilokanir léna show_domain_blocks_rationale: Sýna af hverju lokað var á lén diff --git a/config/locales/simple_form.sq.yml b/config/locales/simple_form.sq.yml index c631d2fcf5..db975ec65a 100644 --- a/config/locales/simple_form.sq.yml +++ b/config/locales/simple_form.sq.yml @@ -56,6 +56,8 @@ sq: scopes: Cilat API do të lejohen të përdorin aplikacioni. Nëse përzgjidhni një shkallë të epërme, nuk ju duhet të përzgjidhni individualet një nga një. setting_aggregate_reblogs: Mos shfaq përforcime të reja për mesazhe që janë përforcuar tani së fundi (prek vetëm përforcime të marra rishtas) setting_always_send_emails: Normalisht s’do të dërgohen njoftime, kur përdorni aktivisht Mastodon-in + setting_default_quote_policy_private: Në Mastodon s’mund të citohen nga të tjerë postim Vetëm-për-ndjekësit. + setting_default_quote_policy_unlisted: Kur njerëzit ju citojnë, nga rrjedha kohore e gjërave në modë do të kalohen si të fshehura edhe postimet e tyre. setting_default_sensitive: Media rezervat fshihet, si parazgjedhje, dhe mund të shfaqet me një klikim setting_display_media_default: Fshih media me shenjën rezervat setting_display_media_hide_all: Fshih përherë mediat @@ -225,6 +227,7 @@ sq: setting_auto_play_gif: Vetëluaji GIF-et e animuar setting_boost_modal: Shfaq dialog ripohimi përpara përforcimi setting_default_language: Gjuhë postimi + setting_default_privacy: Dukshmëri postimi setting_default_quote_policy: Cilët mund të citojnë setting_default_sensitive: Mediave vëru përherë shenjë si rezervat setting_delete_modal: Shfaq dialog ripohimi përpara fshirjes së një mesazhi @@ -315,6 +318,7 @@ sq: follow_request: Dikush kërkoi t’ju ndjekë mention: Dikush ju përmendi pending_account: Llogaria e re lyp shqyrtim + quote: Dikush ju citoi reblog: Dikush përforcoi gjendjen tuaj report: Parashtrohet raportim i ri software_updates: diff --git a/config/locales/simple_form.tr.yml b/config/locales/simple_form.tr.yml index 7d303c1064..eed2a95ef6 100644 --- a/config/locales/simple_form.tr.yml +++ b/config/locales/simple_form.tr.yml @@ -283,12 +283,16 @@ tr: content_cache_retention_period: Uzak içerik saklama süresi custom_css: Özel CSS favicon: Yer imi simgesi + local_live_feed_access: Yerel gönderileri ön plana çıkaran canlı akışlara erişim + local_topic_feed_access: Yerel gönderileri ön plana çıkaran etiket ve bağlantı akışlarına erişim mascot: Özel maskot (eski) media_cache_retention_period: Medya önbelleği saklama süresi min_age: Azami yaş gereksinimi peers_api_enabled: API'de keşfedilen sunucuların listesini yayınla profile_directory: Profil dizinini etkinleştir registrations_mode: Kim kaydolabilir + remote_live_feed_access: Uzaktan gönderileri ön plana çıkaran canlı akışlara erişim + remote_topic_feed_access: Uzaktan gönderileri ön plana çıkaran etiket ve bağlantı akışlarına erişim require_invite_text: Katılmak için bir gerekçe iste show_domain_blocks: Engellenen alan adlarını göster show_domain_blocks_rationale: Alan adlarının neden engellendiğini göster diff --git a/config/locales/simple_form.vi.yml b/config/locales/simple_form.vi.yml index 54b72d982b..0711711e5d 100644 --- a/config/locales/simple_form.vi.yml +++ b/config/locales/simple_form.vi.yml @@ -282,12 +282,16 @@ vi: content_cache_retention_period: Khoảng thời gian lưu giữ nội dung máy chủ khác custom_css: Tùy chỉnh CSS favicon: Favicon + local_live_feed_access: Truy cập bảng tin gồm những tút của máy chủ + local_topic_feed_access: Truy cập hashtag và bảng tin liên kết gồm những tút của máy chủ mascot: Tùy chỉnh linh vật (kế thừa) media_cache_retention_period: Thời hạn lưu trữ cache media min_age: Độ tuổi tối thiểu peers_api_enabled: Công khai danh sách các máy chủ được phát hiện trong API profile_directory: Cho phép hiện danh bạ thành viên registrations_mode: Ai có thể đăng ký + remote_live_feed_access: Truy cập bảng tin gồm những tút từ máy chủ khác + remote_topic_feed_access: Truy cập hashtag và bảng tin liên kết gồm những tút từ máy chủ khác require_invite_text: Yêu cầu lí do đăng ký show_domain_blocks: Xem máy chủ chặn show_domain_blocks_rationale: Hiện lý do máy chủ bị chặn diff --git a/config/locales/simple_form.zh-CN.yml b/config/locales/simple_form.zh-CN.yml index 351656c1b4..dbe628205f 100644 --- a/config/locales/simple_form.zh-CN.yml +++ b/config/locales/simple_form.zh-CN.yml @@ -282,12 +282,16 @@ zh-CN: content_cache_retention_period: 外站内容保留期 custom_css: 自定义 CSS favicon: Favicon + local_live_feed_access: 展示本站嘟文的实时动态访问权限 + local_topic_feed_access: 展示本站嘟文的话题标签及实时动态访问权限 mascot: 自定义吉祥物(旧) media_cache_retention_period: 媒体缓存保留期 min_age: 最低年龄要求 peers_api_enabled: 在API中公开的已知实例的服务器的列表 profile_directory: 启用用户目录 registrations_mode: 谁可以注册 + remote_live_feed_access: 展示外站嘟文的实时动态访问权限 + remote_topic_feed_access: 展示外站嘟文的话题标签及实时动态访问权限 require_invite_text: 注册时需要提供理由 show_domain_blocks: 显示站点屏蔽列表 show_domain_blocks_rationale: 显示站点屏蔽原因 diff --git a/config/locales/simple_form.zh-TW.yml b/config/locales/simple_form.zh-TW.yml index 443d872379..00684c3271 100644 --- a/config/locales/simple_form.zh-TW.yml +++ b/config/locales/simple_form.zh-TW.yml @@ -282,12 +282,16 @@ zh-TW: content_cache_retention_period: 遠端內容保留期限 custom_css: 自訂 CSS favicon: 網站圖示 (Favicon) + local_live_feed_access: 允許瀏覽本站嘟文之即時內容 + local_topic_feed_access: 允許瀏覽本站嘟文之主題標籤與連結 mascot: 自訂吉祥物 (legacy) media_cache_retention_period: 多媒體快取資料保留期間 min_age: 最低年齡要求 peers_api_enabled: 於 API 中公開已知伺服器的列表 profile_directory: 啟用個人檔案目錄 registrations_mode: 誰能註冊 + remote_live_feed_access: 允許瀏覽聯邦宇宙嘟文之即時內容 + remote_topic_feed_access: 允許瀏覽聯邦宇宙嘟文之主題標籤與連結 require_invite_text: 要求「加入原因」 show_domain_blocks: 顯示封鎖的網域 show_domain_blocks_rationale: 顯示網域被封鎖之原因 diff --git a/config/locales/sq.yml b/config/locales/sq.yml index 4b1cf77cb6..b0ce596564 100644 --- a/config/locales/sq.yml +++ b/config/locales/sq.yml @@ -835,6 +835,10 @@ sq: all: Për këdo disabled: Për askënd users: Për përdorues vendorë që kanë bërë hyrjen + feed_access: + modes: + authenticated: Vetëm përdorues të mirëfilltësuar + public: Kushdo registrations: moderation_recommandation: Ju lutemi, sigurohuni si keni një ekip adekuat dhe reagues moderimi, përpara se të hapni regjistrimet për këdo! preamble: Kontrolloni cilët mund të krijojnë llogari në shërbyesin tuaj. @@ -1075,6 +1079,17 @@ sq: other: Përdorur nga %{count} vetë gjatë javës së kaluar title: Rekomandime & Prirje trending: Në modë + username_blocks: + add_new: Shtoni të ri + block_registrations: Blloko regjistrimet + comparison: + contains: Përmban + equals: Është baras me + contains_html: Përmban %{string} + created_msg: U krijua me sukses rregull emrash përdoruesish + delete: Fshije + edit: + title: Përpunoni rregull emrash përdoruesi warning_presets: add_new: Shtoni të ri delete: Fshije @@ -1648,6 +1663,10 @@ sq: title: Përmendje e re poll: subject: Përfundoi një pyetësor nga %{name} + quote: + body: 'Postimi juaj u citua nga %{name}:' + subject: "%{name} citoi postimin tuaj" + title: Citim i ri reblog: body: 'Gjendja juaj u përforcua nga %{name}:' subject: "%{name} përforcoi gjendjen tuaj" @@ -1696,6 +1715,9 @@ sq: self_vote: S’mund të votoni në pyetësorët tuaj too_few_options: duhet të ketë më tepër se një element too_many_options: s’mund të përmbajë më tepër se %{max} elementë + vote: Votoni + posting_defaults: + explanation: Këto rregullime do të përdoren si parazgjedhje, kur krijoni postime të reja, por mund t’i përpunoni për postim, brenda hartuesit. preferences: other: Tjetër posting_defaults: Parazgjedhje postimesh @@ -1864,9 +1886,17 @@ sq: limit: Keni fiksuar tashmë numrin maksimum të mesazheve ownership: S’mund të fiksohen mesazhet e të tjerëve reblog: S’mund të fiksohet një përforcim + quote_policies: + followers: Vetëm ndjekës + nobody: Thjesht unë + public: Cilido title: '%{name}: "%{quote}"' visibilities: + direct: Përmendje private + private: Vetëm ndjekës public: Publike + public_long: Cilido që hyn e del në Mastodon + unlisted_long: Fshehur nga përfundime kërkimi në Mastodon, rrjedha kohore gjërash në modë dhe publike statuses_cleanup: enabled: Fshi automatikisht postime të vjetra enabled_hint: Fshin automatikisht postimet tuaja, pasi mbërrijnë një prag të caktuar moshe, hiq rastin kur ka përputhje me një nga përjashtimet më poshtë diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 50319622df..0c470b29d1 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -848,6 +848,10 @@ tr: all: Herkes için disabled: Hiç kimseye users: Oturum açan yerel kullanıcılara + feed_access: + modes: + authenticated: Sadece yetkilendirilmiş kullanıcılar + public: Herkes registrations: moderation_recommandation: Lütfen kayıtları herkese açmadan önce yeterli ve duyarlı bir denetleyici ekibine sahip olduğunuzdan emin olun! preamble: Sunucunuzda kimin hesap oluşturabileceğini denetleyin. diff --git a/config/locales/vi.yml b/config/locales/vi.yml index 0af555c913..d460f44468 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -834,6 +834,10 @@ vi: all: Tới mọi người disabled: Không ai users: Để đăng nhập người cục bộ + feed_access: + modes: + authenticated: Chỉ những người dùng đã xác minh + public: Mọi người registrations: moderation_recommandation: Vui lòng đảm bảo rằng bạn có một đội ngũ kiểm duyệt và phản ứng nhanh trước khi mở đăng ký cho mọi người! preamble: Kiểm soát những ai có thể tạo tài khoản trên máy chủ của bạn. diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 3200bdfd23..724f7287f5 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -834,6 +834,10 @@ zh-CN: all: 对所有人 disabled: 不对任何人 users: 对已登录的本站用户 + feed_access: + modes: + authenticated: 仅已登录用户 + public: 所有人 registrations: moderation_recommandation: 在向所有人开放注册之前,请确保你拥有一个人手足够且反应迅速的管理团队! preamble: 控制谁可以在你的服务器上创建账号。 @@ -1877,7 +1881,7 @@ zh-CN: direct: 私下提及 private: 仅关注者 public: 公开 - public_long: 所有人(无论是否注册了 Mastodon) + public_long: 任何人(无论是否注册了 Mastodon) unlisted: 悄悄公开 unlisted_long: 不显示在Mastodon的搜索结果、热门趋势、公共时间线上 statuses_cleanup: diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 101ea1850a..c5478d8a64 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -836,6 +836,10 @@ zh-TW: all: 至任何人 disabled: 至沒有人 users: 套用至所有登入的本站使用者 + feed_access: + modes: + authenticated: 僅限已登入之使用者 + public: 任何人 registrations: moderation_recommandation: 對所有人開放註冊之前,請確保您有人手充足且反應靈敏的管理員團隊! preamble: 控制誰能於您伺服器上建立帳號。 From 63bbe4ee163e788ff49b8dff202e762e066fda69 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 7 Oct 2025 14:50:40 +0200 Subject: [PATCH 10/44] Display quotes in email notifications (#36379) --- app/javascript/styles/entrypoints/mailer.scss | 42 +++++++++++++++++++ .../_nested_quote.html.haml | 17 ++++++++ .../notification_mailer/_status.html.haml | 21 +++------- .../notification_mailer/_status.text.erb | 4 ++ .../_status_content.html.haml | 15 +++++++ 5 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 app/views/notification_mailer/_nested_quote.html.haml create mode 100644 app/views/notification_mailer/_status_content.html.haml diff --git a/app/javascript/styles/entrypoints/mailer.scss b/app/javascript/styles/entrypoints/mailer.scss index 7d2a54afae..fcbbd66f4c 100644 --- a/app/javascript/styles/entrypoints/mailer.scss +++ b/app/javascript/styles/entrypoints/mailer.scss @@ -88,6 +88,14 @@ table + p { padding: 24px; } +.email-inner-nested-card-td { + border-radius: 12px; + padding: 18px; + overflow: hidden; + background-color: #fff; + border: 1px solid #dfdee3; +} + // Account .email-account-banner-table { background-color: #f3f2f5; @@ -559,12 +567,29 @@ table + p { } } +.email-quote-header-img { + width: 34px; + + img { + width: 34px; + height: 34px; + border-radius: 8px; + overflow: hidden; + } +} + .email-status-header-text { padding-left: 16px; padding-right: 16px; vertical-align: middle; } +.email-quote-header-text { + padding-left: 14px; + padding-right: 14px; + vertical-align: middle; +} + .email-status-header-name { font-size: 16px; font-weight: 600; @@ -578,6 +603,19 @@ table + p { color: #746a89; } +.email-quote-header-name { + font-size: 14px; + font-weight: 600; + line-height: 18px; + color: #17063b; +} + +.email-quote-header-handle { + font-size: 13px; + line-height: 18px; + color: #746a89; +} + .email-status-content { padding-top: 24px; } @@ -589,6 +627,10 @@ table + p { } .email-status-prose { + .quote-inline { + display: none; + } + p { font-size: 14px; line-height: 20px; diff --git a/app/views/notification_mailer/_nested_quote.html.haml b/app/views/notification_mailer/_nested_quote.html.haml new file mode 100644 index 0000000000..e66736399f --- /dev/null +++ b/app/views/notification_mailer/_nested_quote.html.haml @@ -0,0 +1,17 @@ +%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-quote-header-img + = image_tag full_asset_url(status.account.avatar.url), alt: '', width: 34, height: 34 + %td.email-quote-header-text + %h2.email-quote-header-name + = display_name(status.account) + %p.email-quote-header-handle + @#{status.account.pretty_acct} + +%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-status-content + = render 'status_content', status: status + + %p.email-status-footer + = link_to l(status.created_at.in_time_zone(time_zone.presence), format: :with_time_zone), web_url("@#{status.account.pretty_acct}/#{status.id}") diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml index bf38dc9aa2..064709e7da 100644 --- a/app/views/notification_mailer/_status.html.haml +++ b/app/views/notification_mailer/_status.html.haml @@ -11,21 +11,12 @@ %table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } %tr %td.email-status-content - .auto-dir - - if status.spoiler_text? - %p.email-status-spoiler - = status.spoiler_text - - .email-status-prose - = status_content_format(status) - - - if status.ordered_media_attachments.size.positive? - %p.email-status-media - - status.ordered_media_attachments.each do |a| - - if status.local? - = link_to full_asset_url(a.file.url(:original)), full_asset_url(a.file.url(:original)) - - else - = link_to a.remote_url, a.remote_url + = render 'status_content', status: status + - if status.local? && status.quote + %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-inner-nested-card-td + = render 'nested_quote', status: status.quote.quoted_status, time_zone: time_zone %p.email-status-footer = link_to l(status.created_at.in_time_zone(time_zone.presence), format: :with_time_zone), web_url("@#{status.account.pretty_acct}/#{status.id}") diff --git a/app/views/notification_mailer/_status.text.erb b/app/views/notification_mailer/_status.text.erb index e03e8346c1..13711ee74d 100644 --- a/app/views/notification_mailer/_status.text.erb +++ b/app/views/notification_mailer/_status.text.erb @@ -4,5 +4,9 @@ > <% end %> > <%= raw word_wrap(extract_status_plain_text(status), break_sequence: "\n> ") %> +<% if status.local? && status.quote %> +> +>> <%= raw word_wrap(extract_status_plain_text(status.quote.quoted_status), break_sequence: "\n>> ") %> +<% end %> <%= raw t('application_mailer.view')%> <%= web_url("@#{status.account.pretty_acct}/#{status.id}") %> diff --git a/app/views/notification_mailer/_status_content.html.haml b/app/views/notification_mailer/_status_content.html.haml new file mode 100644 index 0000000000..f95ba8ccba --- /dev/null +++ b/app/views/notification_mailer/_status_content.html.haml @@ -0,0 +1,15 @@ +.auto-dir + - if status.spoiler_text? + %p.email-status-spoiler + = status.spoiler_text + + .email-status-prose + = status_content_format(status) + + - if status.ordered_media_attachments.size.positive? + %p.email-status-media + - status.ordered_media_attachments.each do |a| + - if status.local? + = link_to full_asset_url(a.file.url(:original)), full_asset_url(a.file.url(:original)) + - else + = link_to a.remote_url, a.remote_url From d51717c10143d08ee472871142dfea44afb37e75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:29:04 +0200 Subject: [PATCH 11/44] Update dependency vite to v7.1.9 (#36332) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 766dba2fc0..ce36deec7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13878,8 +13878,8 @@ __metadata: linkType: hard "vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.1.1": - version: 7.1.7 - resolution: "vite@npm:7.1.7" + version: 7.1.9 + resolution: "vite@npm:7.1.9" dependencies: esbuild: "npm:^0.25.0" fdir: "npm:^6.5.0" @@ -13928,7 +13928,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/3f6bd61a65aaa81368f4dda804f0e23b103664724218ccb5a0b1a0c7e284df498107b57ced951dc40ae4c5d472435bc8fb5c836414e729ee7e102809eaf6ff80 + checksum: 10c0/f628f903a137c1410232558bde99c223ea00a090bda6af77752c61f912955f0050aac12d3cfe024d08a0f150ff6fab61b3d0be75d634a59b94d49f525392e1f7 languageName: node linkType: hard From fb6fd7b7e1e59d762efe90b5eea5bbe16319a446 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:29:19 +0000 Subject: [PATCH 12/44] Update dependency pino to v9.13.1 (#36337) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ce36deec7e..14b17f3780 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10339,8 +10339,8 @@ __metadata: linkType: hard "pino@npm:^9.0.0": - version: 9.12.0 - resolution: "pino@npm:9.12.0" + version: 9.13.1 + resolution: "pino@npm:9.13.1" dependencies: atomic-sleep: "npm:^1.0.0" on-exit-leak-free: "npm:^2.1.0" @@ -10355,7 +10355,7 @@ __metadata: thread-stream: "npm:^3.0.0" bin: pino: bin.js - checksum: 10c0/5cfe093e972a8471a90f7f380c01379eed3fd937038acb97d1de9180f097c044855ca89a2e70baa699aec3e8dcaec037d03e2c90dde235102a3e17b40f54cc1f + checksum: 10c0/c99e879f9538f7255488ad276a46a857cf9114217b754b850b7f1441e31b724a6d6f0697228ead954d3d9601522704e03cad5d441c228108073eed2f37ea0e41 languageName: node linkType: hard From da6ae98e576dd223e714e7f6c604d39f5841a4ae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 16:29:58 +0200 Subject: [PATCH 13/44] Update dependency ioredis to v5.8.1 (#36361) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 14b17f3780..5a773c8f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8340,8 +8340,8 @@ __metadata: linkType: hard "ioredis@npm:^5.3.2": - version: 5.8.0 - resolution: "ioredis@npm:5.8.0" + version: 5.8.1 + resolution: "ioredis@npm:5.8.1" dependencies: "@ioredis/commands": "npm:1.4.0" cluster-key-slot: "npm:^1.1.0" @@ -8352,7 +8352,7 @@ __metadata: redis-errors: "npm:^1.2.0" redis-parser: "npm:^3.0.0" standard-as-callback: "npm:^2.1.0" - checksum: 10c0/66fad6283c6d9052b4aa0987d592c1bf6c9471304eb0edf0c9d18024b1b38028adf29c05f1cf114b90f5bdb516576f897a654946e8c29568f404ac33cd3b9d19 + checksum: 10c0/4ed66444017150da027bce940a24bf726994691e2a7b3aa11d52f8aeb37f258068cc171af4d9c61247acafc28eb086fa8a7c79420b8e8d2907d2f74f39584465 languageName: node linkType: hard From c578a0cb74bdc6492bed58007f8b7971eef43e30 Mon Sep 17 00:00:00 2001 From: Brad Dunbar Date: Tue, 7 Oct 2025 10:42:15 -0400 Subject: [PATCH 14/44] Resolve typescript eslint warning (#36314) --- app/javascript/mastodon/polyfills/index.ts | 2 +- app/javascript/mastodon/reducers/modal.ts | 2 +- package.json | 2 +- yarn.lock | 156 ++++++++++++++++++++- 4 files changed, 157 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/polyfills/index.ts b/app/javascript/mastodon/polyfills/index.ts index 0ff0dd7269..1abfe0a935 100644 --- a/app/javascript/mastodon/polyfills/index.ts +++ b/app/javascript/mastodon/polyfills/index.ts @@ -19,7 +19,7 @@ export function loadPolyfills() { return Promise.all([ loadIntlPolyfills(), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types - needsExtraPolyfills && importExtraPolyfills(), + needsExtraPolyfills ? importExtraPolyfills() : Promise.resolve(), loadEmojiPolyfills(), ]); } diff --git a/app/javascript/mastodon/reducers/modal.ts b/app/javascript/mastodon/reducers/modal.ts index e287626ff2..dfdff7cf03 100644 --- a/app/javascript/mastodon/reducers/modal.ts +++ b/app/javascript/mastodon/reducers/modal.ts @@ -41,7 +41,7 @@ const popModal = ( modalType === state.get('stack').get(0)?.get('modalType') ) { return state - .set('ignoreFocus', !!ignoreFocus) + .set('ignoreFocus', ignoreFocus) .update('stack', (stack) => stack.shift()); } else { return state; diff --git a/package.json b/package.json index 0fd14de656..4596fc421a 100644 --- a/package.json +++ b/package.json @@ -191,7 +191,7 @@ "stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-standard-scss": "^15.0.1", "typescript": "~5.9.0", - "typescript-eslint": "^8.29.1", + "typescript-eslint": "^8.45.0", "vitest": "^3.2.4" }, "resolutions": { diff --git a/yarn.lock b/yarn.lock index 5a773c8f32..5faccd97b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2860,7 +2860,7 @@ __metadata: tiny-queue: "npm:^0.2.1" twitter-text: "npm:3.1.0" typescript: "npm:~5.9.0" - typescript-eslint: "npm:^8.29.1" + typescript-eslint: "npm:^8.45.0" use-debounce: "npm:^10.0.0" vite: "npm:^7.1.1" vite-plugin-manifest-sri: "npm:^0.2.0" @@ -4524,6 +4524,27 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/eslint-plugin@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.45.0" + dependencies: + "@eslint-community/regexpp": "npm:^4.10.0" + "@typescript-eslint/scope-manager": "npm:8.45.0" + "@typescript-eslint/type-utils": "npm:8.45.0" + "@typescript-eslint/utils": "npm:8.45.0" + "@typescript-eslint/visitor-keys": "npm:8.45.0" + graphemer: "npm:^1.4.0" + ignore: "npm:^7.0.0" + natural-compare: "npm:^1.4.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + "@typescript-eslint/parser": ^8.45.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/0c60a0e5d07fa8618348db38b5a81e66143d528e1b3cdb5678bbc6c60590cd559b27c98c36f5663230fc4cf6920dff2cd604de30b58df26a37fcfcc5dc1dbd45 + languageName: node + linkType: hard + "@typescript-eslint/parser@npm:8.38.0": version: 8.38.0 resolution: "@typescript-eslint/parser@npm:8.38.0" @@ -4540,6 +4561,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/parser@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/parser@npm:8.45.0" + dependencies: + "@typescript-eslint/scope-manager": "npm:8.45.0" + "@typescript-eslint/types": "npm:8.45.0" + "@typescript-eslint/typescript-estree": "npm:8.45.0" + "@typescript-eslint/visitor-keys": "npm:8.45.0" + debug: "npm:^4.3.4" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/8b419bcf795b112a39fcac05dcf147835059345b6399035ffa3f76a9d8e320f3fac79cae2fe4320dcda83fa059b017ca7626a7b4e3da08a614415c8867d169b8 + languageName: node + linkType: hard + "@typescript-eslint/project-service@npm:8.38.0": version: 8.38.0 resolution: "@typescript-eslint/project-service@npm:8.38.0" @@ -4553,6 +4590,19 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/project-service@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/project-service@npm:8.45.0" + dependencies: + "@typescript-eslint/tsconfig-utils": "npm:^8.45.0" + "@typescript-eslint/types": "npm:^8.45.0" + debug: "npm:^4.3.4" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/98af065a1a3ed9d3d1eb265e09d3e9c2ae676d500a8c1d764f5609fe2c1b86749516b709804eb814fae688be7809d11748b9ae691d43c28da51dac390ca81fa9 + languageName: node + linkType: hard + "@typescript-eslint/scope-manager@npm:8.38.0": version: 8.38.0 resolution: "@typescript-eslint/scope-manager@npm:8.38.0" @@ -4563,6 +4613,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/scope-manager@npm:8.45.0" + dependencies: + "@typescript-eslint/types": "npm:8.45.0" + "@typescript-eslint/visitor-keys": "npm:8.45.0" + checksum: 10c0/54cd36206f6b4fc8e1e48576ed01e0d6ab20c2a9c4c7d90d5cc3a2d317dd8a13abe148ffecf471b16f1224aba5749e0905472745626bef9ae5bed771776f4abe + languageName: node + linkType: hard + "@typescript-eslint/tsconfig-utils@npm:8.38.0, @typescript-eslint/tsconfig-utils@npm:^8.38.0": version: 8.38.0 resolution: "@typescript-eslint/tsconfig-utils@npm:8.38.0" @@ -4572,6 +4632,15 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/tsconfig-utils@npm:8.45.0, @typescript-eslint/tsconfig-utils@npm:^8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.45.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/227a9b7a5baaf35466fd369992cb933192515df1156ddf22f438deb344c2523695208e1036f5590b20603f31724de75a47fe0ee84e2fd4c8e9f3606f23f68112 + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:8.38.0": version: 8.38.0 resolution: "@typescript-eslint/type-utils@npm:8.38.0" @@ -4588,6 +4657,22 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/type-utils@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/type-utils@npm:8.45.0" + dependencies: + "@typescript-eslint/types": "npm:8.45.0" + "@typescript-eslint/typescript-estree": "npm:8.45.0" + "@typescript-eslint/utils": "npm:8.45.0" + debug: "npm:^4.3.4" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/ce0f4c209c2418ebeb65e7de053499fb68bf6000bdd71068594fdb8c8ac3dbbd62935a3cea233989491f7da3ef5db87e7efd2910133c6abf6d0cbf57248f6442 + languageName: node + linkType: hard + "@typescript-eslint/types@npm:8.38.0, @typescript-eslint/types@npm:^8.34.1, @typescript-eslint/types@npm:^8.38.0": version: 8.38.0 resolution: "@typescript-eslint/types@npm:8.38.0" @@ -4595,6 +4680,13 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/types@npm:8.45.0, @typescript-eslint/types@npm:^8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/types@npm:8.45.0" + checksum: 10c0/0213a0573c671d13bc91961a2b2e814ec7f6381ff093bce6704017bd96b2fc7fee25906c815cedb32a0601cf5071ca6c7c5f940d087c3b0d3dd7d4bc03478278 + languageName: node + linkType: hard + "@typescript-eslint/typescript-estree@npm:8.38.0": version: 8.38.0 resolution: "@typescript-eslint/typescript-estree@npm:8.38.0" @@ -4615,6 +4707,26 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.45.0" + dependencies: + "@typescript-eslint/project-service": "npm:8.45.0" + "@typescript-eslint/tsconfig-utils": "npm:8.45.0" + "@typescript-eslint/types": "npm:8.45.0" + "@typescript-eslint/visitor-keys": "npm:8.45.0" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^2.1.0" + peerDependencies: + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/8c2f44a00fe859a6cd4b50157c484c5b6a1c7af5d48e89ae79c5f4924947964962fc8f478ad4c2ade788907fceee9b72d4e376508ea79b51392f91082a37d239 + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:8.38.0, @typescript-eslint/utils@npm:^8.27.0, @typescript-eslint/utils@npm:^8.8.1": version: 8.38.0 resolution: "@typescript-eslint/utils@npm:8.38.0" @@ -4630,6 +4742,21 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/utils@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/utils@npm:8.45.0" + dependencies: + "@eslint-community/eslint-utils": "npm:^4.7.0" + "@typescript-eslint/scope-manager": "npm:8.45.0" + "@typescript-eslint/types": "npm:8.45.0" + "@typescript-eslint/typescript-estree": "npm:8.45.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/b3c83a23813b15e20e303d7153789508c01e06dec355b1a80547c59aa36998d498102f45fcd13f111031fac57270608abb04d20560248d4448fd00b1cf4dc4ab + languageName: node + linkType: hard + "@typescript-eslint/visitor-keys@npm:8.38.0": version: 8.38.0 resolution: "@typescript-eslint/visitor-keys@npm:8.38.0" @@ -4640,6 +4767,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.45.0": + version: 8.45.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.45.0" + dependencies: + "@typescript-eslint/types": "npm:8.45.0" + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10c0/119adcf50c902dad7f7757bcdd88fad0a23a171d309d9b7cefe78af12e451cf84c04ae611f4c31f7e23f16c2b47665ad92e6e5648fc77d542ef306f465bf1f29 + languageName: node + linkType: hard + "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1" @@ -13420,7 +13557,7 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.28.0, typescript-eslint@npm:^8.29.1": +"typescript-eslint@npm:^8.28.0": version: 8.38.0 resolution: "typescript-eslint@npm:8.38.0" dependencies: @@ -13435,6 +13572,21 @@ __metadata: languageName: node linkType: hard +"typescript-eslint@npm:^8.45.0": + version: 8.45.0 + resolution: "typescript-eslint@npm:8.45.0" + dependencies: + "@typescript-eslint/eslint-plugin": "npm:8.45.0" + "@typescript-eslint/parser": "npm:8.45.0" + "@typescript-eslint/typescript-estree": "npm:8.45.0" + "@typescript-eslint/utils": "npm:8.45.0" + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: ">=4.8.4 <6.0.0" + checksum: 10c0/2342b0bffe6f719711adbb42116f90cb1fe1670e2e74dde2739482c9d61c2a975ee16e2d560684613050544b543342ec1b11b46e158a48ecc605f5882d2d5da7 + languageName: node + linkType: hard + "typescript@npm:^5.6.0, typescript@npm:~5.9.0": version: 5.9.2 resolution: "typescript@npm:5.9.2" From 3c9b828c714c71598493fe39231175bb156ef6fd Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 7 Oct 2025 17:21:50 +0200 Subject: [PATCH 15/44] Emoji: Bypass legacy emoji normalization (#36377) --- app/javascript/entrypoints/public.tsx | 2 +- app/javascript/mastodon/components/account_bio.tsx | 2 +- .../mastodon/components/display_name/no-domain.tsx | 8 +------- .../mastodon/components/display_name/simple.tsx | 8 +------- .../mastodon/components/status_content.jsx | 3 --- app/javascript/mastodon/features/emoji/emoji.js | 13 ++++++++++++- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index dd1956446d..fea3eb0d79 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -70,7 +70,7 @@ function loaded() { }; document.querySelectorAll('.emojify').forEach((content) => { - content.innerHTML = emojify(content.innerHTML); + content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system. }); document diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 64e5cc0457..6c9ea43a40 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -61,7 +61,7 @@ export const AccountBio: React.FC = ({ if (!account) { return ''; } - return isModernEmojiEnabled() ? account.note : account.note_emojified; + return account.note_emojified; }); const extraEmojis = useAppSelector((state) => { const account = state.accounts.get(accountId); diff --git a/app/javascript/mastodon/components/display_name/no-domain.tsx b/app/javascript/mastodon/components/display_name/no-domain.tsx index bb5a093659..ee6e84050c 100644 --- a/app/javascript/mastodon/components/display_name/no-domain.tsx +++ b/app/javascript/mastodon/components/display_name/no-domain.tsx @@ -2,8 +2,6 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; import classNames from 'classnames'; -import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; - import { AnimateEmojiProvider } from '../emoji/context'; import { EmojiHTML } from '../emoji/html'; import { Skeleton } from '../skeleton'; @@ -24,11 +22,7 @@ export const DisplayNameWithoutDomain: FC< {account ? ( diff --git a/app/javascript/mastodon/components/display_name/simple.tsx b/app/javascript/mastodon/components/display_name/simple.tsx index 375f4932b2..29d9ee217b 100644 --- a/app/javascript/mastodon/components/display_name/simple.tsx +++ b/app/javascript/mastodon/components/display_name/simple.tsx @@ -1,7 +1,5 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; -import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; - import { EmojiHTML } from '../emoji/html'; import type { DisplayNameProps } from './index'; @@ -19,11 +17,7 @@ export const DisplayNameSimple: FC< diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 5b10d45e84..ede98cc81a 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -28,9 +28,6 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) * @returns {string} */ export function getStatusContent(status) { - if (isModernEmojiEnabled()) { - return status.getIn(['translation', 'content']) || status.get('content'); - } return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); } diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index d1843c33bd..cc0d6fe195 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,5 +1,6 @@ import Trie from 'substring-trie'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { assetHost } from 'mastodon/utils/config'; import { autoPlayGif } from '../../initial_state'; @@ -148,7 +149,17 @@ const emojifyNode = (node, customEmojis) => { } }; -const emojify = (str, customEmojis = {}) => { +/** + * Legacy emoji processing function. + * @param {string} str + * @param {object} customEmojis + * @param {boolean} force If true, always emojify even if modern emoji is enabled + * @returns {string} + */ +const emojify = (str, customEmojis = {}, force = false) => { + if (isModernEmojiEnabled() && !force) { + return str; + } const wrapper = document.createElement('div'); wrapper.innerHTML = str; From e02ea3e1109b717ffc7fc178ec62eb460cab9703 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 7 Oct 2025 17:22:00 +0200 Subject: [PATCH 16/44] Emoji: Compare history modal (#36378) --- .../ui/components/compare_history_modal.jsx | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx index 4227c74131..c9a5826566 100644 --- a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx @@ -15,6 +15,8 @@ import InlineAccount from 'mastodon/components/inline_account'; import MediaAttachments from 'mastodon/components/media_attachments'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import emojify from 'mastodon/features/emoji/emoji'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; +import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; const mapStateToProps = (state, { statusId }) => ({ language: state.getIn(['statuses', statusId, 'language']), @@ -51,8 +53,8 @@ class CompareHistoryModal extends PureComponent { return obj; }, {}); - const content = { __html: emojify(currentVersion.get('content'), emojiMap) }; - const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) }; + const content = emojify(currentVersion.get('content'), emojiMap); + const spoilerContent = emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap); const formattedDate = ; const formattedName = ; @@ -65,43 +67,50 @@ class CompareHistoryModal extends PureComponent { return (
-
- - {label} -
- -
-
- {currentVersion.get('spoiler_text').length > 0 && ( - <> -
-
- - )} - -
- - {!!currentVersion.get('poll') && ( -
-
    - {currentVersion.getIn(['poll', 'options']).map(option => ( -
  • - - - -
  • - ))} -
-
- )} - - + +
+ + {label}
-
+ +
+
+ {currentVersion.get('spoiler_text').length > 0 && ( + <> + +
+ + )} + + + + {!!currentVersion.get('poll') && ( +
+
    + {currentVersion.getIn(['poll', 'options']).map(option => ( +
  • + + + +
  • + ))} +
+
+ )} + + +
+
+
); } From bc7119b3cb89f8341013bf08e7dc4d458d694af0 Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 7 Oct 2025 17:34:06 +0200 Subject: [PATCH 17/44] Remove unused feature flag from sample configuration file (#36382) --- .env.production.sample | 3 --- 1 file changed, 3 deletions(-) diff --git a/.env.production.sample b/.env.production.sample index 15004b9d0d..8ea569fb01 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -91,9 +91,6 @@ SESSION_RETENTION_PERIOD=31556952 # Fetch All Replies Behavior # -------------------------- -# When a user expands a post (DetailedStatus view), fetch all of its replies -# (default: false) -FETCH_REPLIES_ENABLED=false # Period to wait between fetching replies (in minutes) FETCH_REPLIES_COOLDOWN_MINUTES=15 From aa7bcd3ae3ec7e10e31549f6f3df67c7dcb65c72 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:34:45 +0000 Subject: [PATCH 18/44] Update formatjs monorepo (#36356) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 315 +++++++++++++++++++++--------------------------------- 1 file changed, 122 insertions(+), 193 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5faccd97b2..6dc3809d4b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2397,6 +2397,18 @@ __metadata: languageName: node linkType: hard +"@formatjs/ecma402-abstract@npm:2.3.5": + version: 2.3.5 + resolution: "@formatjs/ecma402-abstract@npm:2.3.5" + dependencies: + "@formatjs/fast-memoize": "npm:2.2.7" + "@formatjs/intl-localematcher": "npm:0.6.2" + decimal.js: "npm:^10.4.3" + tslib: "npm:^2.8.0" + checksum: 10c0/c6cac6312a1228347adf3a6a5fd09c0593e7d199e7a56484ece7cb8121c4138ba5c2777b70f82e88d8fbe3199ef5f48b2b1444f0348ee315b930e9db33c67d84 + languageName: node + linkType: hard + "@formatjs/fast-memoize@npm:2.2.7": version: 2.2.7 resolution: "@formatjs/fast-memoize@npm:2.2.7" @@ -2417,6 +2429,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/icu-messageformat-parser@npm:2.11.3": + version: 2.11.3 + resolution: "@formatjs/icu-messageformat-parser@npm:2.11.3" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.5" + "@formatjs/icu-skeleton-parser": "npm:1.8.15" + tslib: "npm:^2.8.0" + checksum: 10c0/dfa08a671318bc9425f9b8e77ba0fb11856c9e1bb366b2a9c820212711d3483d9337fba50ef0a65c259c5564a6306355b065d739feae56e03b3046edba739460 + languageName: node + linkType: hard + "@formatjs/icu-skeleton-parser@npm:1.8.14": version: 1.8.14 resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14" @@ -2427,6 +2450,16 @@ __metadata: languageName: node linkType: hard +"@formatjs/icu-skeleton-parser@npm:1.8.15": + version: 1.8.15 + resolution: "@formatjs/icu-skeleton-parser@npm:1.8.15" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.5" + tslib: "npm:^2.8.0" + checksum: 10c0/e33478f3cdb6d49f8531f35fb80db98d49533add42cd4ab8d3f3cef72c3496ae3042dfe24f252e6afffd3e4f6c9f1dec88367973c14e779dc07947c75641cede + languageName: node + linkType: hard + "@formatjs/intl-localematcher@npm:0.6.1": version: 0.6.1 resolution: "@formatjs/intl-localematcher@npm:0.6.1" @@ -2436,33 +2469,42 @@ __metadata: languageName: node linkType: hard -"@formatjs/intl-pluralrules@npm:^5.4.4": - version: 5.4.4 - resolution: "@formatjs/intl-pluralrules@npm:5.4.4" +"@formatjs/intl-localematcher@npm:0.6.2": + version: 0.6.2 + resolution: "@formatjs/intl-localematcher@npm:0.6.2" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" - "@formatjs/intl-localematcher": "npm:0.6.1" - decimal.js: "npm:^10.4.3" tslib: "npm:^2.8.0" - checksum: 10c0/8a8ec9f2fad40d9fa654a68de06fb18aaa6f0eafa908f41397f057366740625c12da627c6de68e0396fcd67ceaaa2c5c20a4b102f71ac8694abd9e76cceca949 + checksum: 10c0/22a17a4c67160b6c9f52667914acfb7b79cd6d80630d4ac6d4599ce447cb89d2a64f7d58fa35c3145ddb37fef893f0a45b9a55e663a4eb1f2ae8b10a89fac235 languageName: node linkType: hard -"@formatjs/intl@npm:3.1.6": - version: 3.1.6 - resolution: "@formatjs/intl@npm:3.1.6" +"@formatjs/intl-pluralrules@npm:^5.4.4": + version: 5.4.5 + resolution: "@formatjs/intl-pluralrules@npm:5.4.5" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" + "@formatjs/ecma402-abstract": "npm:2.3.5" + "@formatjs/intl-localematcher": "npm:0.6.2" + decimal.js: "npm:^10.4.3" + tslib: "npm:^2.8.0" + checksum: 10c0/2405fd2a4c8ce7a5c25ae824daa1408b07664a4f5ca573683fedad78a487a118b50391b0a2234db921c615e1ed4f53e860a55cd9892708ae49f5766980495b6e + languageName: node + linkType: hard + +"@formatjs/intl@npm:3.1.7": + version: 3.1.7 + resolution: "@formatjs/intl@npm:3.1.7" + dependencies: + "@formatjs/ecma402-abstract": "npm:2.3.5" "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/icu-messageformat-parser": "npm:2.11.2" - intl-messageformat: "npm:10.7.16" + "@formatjs/icu-messageformat-parser": "npm:2.11.3" + intl-messageformat: "npm:10.7.17" tslib: "npm:^2.8.0" peerDependencies: - typescript: ^5.6.0 + typescript: 5.8.3 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/a31f8d2569c9f2384f67a76f1cc2c8bfc2721c97a7dee0e971b6cfc0f223449bab0cfdc29140e3b71d74b04573c20ee8600909d256293e296a809da69a141530 + checksum: 10c0/52d800f587ed11407879f44b971a498844f116d966f9348ef1b597b7a4ee91f8023e0270797961be8126989512859f5944c4115e39675f4959a22b7837b62e77 languageName: node linkType: hard @@ -2486,6 +2528,25 @@ __metadata: languageName: node linkType: hard +"@formatjs/ts-transformer@npm:3.14.1": + version: 3.14.1 + resolution: "@formatjs/ts-transformer@npm:3.14.1" + dependencies: + "@formatjs/icu-messageformat-parser": "npm:2.11.3" + "@types/node": "npm:^22.0.0" + chalk: "npm:^4.1.2" + json-stable-stringify: "npm:^1.3.0" + tslib: "npm:^2.8.0" + typescript: "npm:5.8.3" + peerDependencies: + ts-jest: ^29 + peerDependenciesMeta: + ts-jest: + optional: true + checksum: 10c0/ed412d70fb0b8a57b74f1453e2605481295fda71ba462f4c65fab3d1a7f5a0c51c70fa78b9e9e2b291917eb63bbc5cffc7df27f04645832ae64e881e1327a11c + languageName: node + linkType: hard + "@gamestdio/websocket@npm:^0.3.2": version: 0.3.2 resolution: "@gamestdio/websocket@npm:0.3.2" @@ -4503,27 +4564,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.38.0" - dependencies: - "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.38.0" - "@typescript-eslint/type-utils": "npm:8.38.0" - "@typescript-eslint/utils": "npm:8.38.0" - "@typescript-eslint/visitor-keys": "npm:8.38.0" - graphemer: "npm:^1.4.0" - ignore: "npm:^7.0.0" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.1.0" - peerDependencies: - "@typescript-eslint/parser": ^8.38.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/199b82e9f0136baecf515df7c31bfed926a7c6d4e6298f64ee1a77c8bdd7a8cb92a2ea55a5a345c9f2948a02f7be6d72530efbe803afa1892b593fbd529d0c27 - languageName: node - linkType: hard - "@typescript-eslint/eslint-plugin@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/eslint-plugin@npm:8.45.0" @@ -4545,22 +4585,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/parser@npm:8.38.0" - dependencies: - "@typescript-eslint/scope-manager": "npm:8.38.0" - "@typescript-eslint/types": "npm:8.38.0" - "@typescript-eslint/typescript-estree": "npm:8.38.0" - "@typescript-eslint/visitor-keys": "npm:8.38.0" - debug: "npm:^4.3.4" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/5580c2a328f0c15f85e4a0961a07584013cc0aca85fe868486187f7c92e9e3f6602c6e3dab917b092b94cd492ed40827c6f5fea42730bef88eb17592c947adf4 - languageName: node - linkType: hard - "@typescript-eslint/parser@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/parser@npm:8.45.0" @@ -4577,19 +4601,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/project-service@npm:8.38.0" - dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.38.0" - "@typescript-eslint/types": "npm:^8.38.0" - debug: "npm:^4.3.4" - peerDependencies: - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/87d2f55521e289bbcdc666b1f4587ee2d43039cee927310b05abaa534b528dfb1b5565c1545bb4996d7fbdf9d5a3b0aa0e6c93a8f1289e3fcfd60d246364a884 - languageName: node - linkType: hard - "@typescript-eslint/project-service@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/project-service@npm:8.45.0" @@ -4603,16 +4614,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/scope-manager@npm:8.38.0" - dependencies: - "@typescript-eslint/types": "npm:8.38.0" - "@typescript-eslint/visitor-keys": "npm:8.38.0" - checksum: 10c0/ceaf489ea1f005afb187932a7ee363dfe1e0f7cc3db921283991e20e4c756411a5e25afbec72edd2095d6a4384f73591f4c750cf65b5eaa650c90f64ef9fe809 - languageName: node - linkType: hard - "@typescript-eslint/scope-manager@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/scope-manager@npm:8.45.0" @@ -4623,15 +4624,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.38.0, @typescript-eslint/tsconfig-utils@npm:^8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.38.0" - peerDependencies: - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/1a90da16bf1f7cfbd0303640a8ead64a0080f2b1d5969994bdac3b80abfa1177f0c6fbf61250bae082e72cf5014308f2f5cc98edd6510202f13420a7ffd07a84 - languageName: node - linkType: hard - "@typescript-eslint/tsconfig-utils@npm:8.45.0, @typescript-eslint/tsconfig-utils@npm:^8.45.0": version: 8.45.0 resolution: "@typescript-eslint/tsconfig-utils@npm:8.45.0" @@ -4641,22 +4633,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/type-utils@npm:8.38.0" - dependencies: - "@typescript-eslint/types": "npm:8.38.0" - "@typescript-eslint/typescript-estree": "npm:8.38.0" - "@typescript-eslint/utils": "npm:8.38.0" - debug: "npm:^4.3.4" - ts-api-utils: "npm:^2.1.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/27795c4bd0be395dda3424e57d746639c579b7522af1c17731b915298a6378fd78869e8e141526064b6047db2c86ba06444469ace19c98cda5779d06f4abd37c - languageName: node - linkType: hard - "@typescript-eslint/type-utils@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/type-utils@npm:8.45.0" @@ -4673,40 +4649,13 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:8.38.0, @typescript-eslint/types@npm:^8.34.1, @typescript-eslint/types@npm:^8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/types@npm:8.38.0" - checksum: 10c0/f0ac0060c98c0f3d1871f107177b6ae25a0f1846ca8bd8cfc7e1f1dd0ddce293cd8ac4a5764d6a767de3503d5d01defcd68c758cb7ba6de52f82b209a918d0d2 - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.45.0, @typescript-eslint/types@npm:^8.45.0": +"@typescript-eslint/types@npm:8.45.0, @typescript-eslint/types@npm:^8.34.1, @typescript-eslint/types@npm:^8.45.0": version: 8.45.0 resolution: "@typescript-eslint/types@npm:8.45.0" checksum: 10c0/0213a0573c671d13bc91961a2b2e814ec7f6381ff093bce6704017bd96b2fc7fee25906c815cedb32a0601cf5071ca6c7c5f940d087c3b0d3dd7d4bc03478278 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.38.0" - dependencies: - "@typescript-eslint/project-service": "npm:8.38.0" - "@typescript-eslint/tsconfig-utils": "npm:8.38.0" - "@typescript-eslint/types": "npm:8.38.0" - "@typescript-eslint/visitor-keys": "npm:8.38.0" - debug: "npm:^4.3.4" - fast-glob: "npm:^3.3.2" - is-glob: "npm:^4.0.3" - minimatch: "npm:^9.0.4" - semver: "npm:^7.6.0" - ts-api-utils: "npm:^2.1.0" - peerDependencies: - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/00a00f6549877f4ae5c2847fa5ac52bf42cbd59a87533856c359e2746e448ed150b27a6137c92fd50c06e6a4b39e386d6b738fac97d80d05596e81ce55933230 - languageName: node - linkType: hard - "@typescript-eslint/typescript-estree@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/typescript-estree@npm:8.45.0" @@ -4727,22 +4676,7 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.38.0, @typescript-eslint/utils@npm:^8.27.0, @typescript-eslint/utils@npm:^8.8.1": - version: 8.38.0 - resolution: "@typescript-eslint/utils@npm:8.38.0" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.7.0" - "@typescript-eslint/scope-manager": "npm:8.38.0" - "@typescript-eslint/types": "npm:8.38.0" - "@typescript-eslint/typescript-estree": "npm:8.38.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/e97a45bf44f315f9ed8c2988429e18c88e3369c9ee3227ee86446d2d49f7325abebbbc9ce801e178f676baa986d3e1fd4b5391f1640c6eb8944c123423ae43bb - languageName: node - linkType: hard - -"@typescript-eslint/utils@npm:8.45.0": +"@typescript-eslint/utils@npm:8.45.0, @typescript-eslint/utils@npm:^8.27.0, @typescript-eslint/utils@npm:^8.8.1": version: 8.45.0 resolution: "@typescript-eslint/utils@npm:8.45.0" dependencies: @@ -4757,16 +4691,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.38.0": - version: 8.38.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.38.0" - dependencies: - "@typescript-eslint/types": "npm:8.38.0" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/071a756e383f41a6c9e51d78c8c64bd41cd5af68b0faef5fbaec4fa5dbd65ec9e4cd610c2e2cdbe9e2facc362995f202850622b78e821609a277b5b601a1d4ec - languageName: node - linkType: hard - "@typescript-eslint/visitor-keys@npm:8.45.0": version: 8.45.0 resolution: "@typescript-eslint/visitor-keys@npm:8.45.0" @@ -5543,21 +5467,21 @@ __metadata: linkType: hard "babel-plugin-formatjs@npm:^10.5.37": - version: 10.5.39 - resolution: "babel-plugin-formatjs@npm:10.5.39" + version: 10.5.40 + resolution: "babel-plugin-formatjs@npm:10.5.40" dependencies: "@babel/core": "npm:^7.26.10" "@babel/helper-plugin-utils": "npm:^7.26.5" "@babel/plugin-syntax-jsx": "npm:^7.25.9" "@babel/traverse": "npm:^7.26.10" "@babel/types": "npm:^7.26.10" - "@formatjs/icu-messageformat-parser": "npm:2.11.2" - "@formatjs/ts-transformer": "npm:3.14.0" + "@formatjs/icu-messageformat-parser": "npm:2.11.3" + "@formatjs/ts-transformer": "npm:3.14.1" "@types/babel__core": "npm:^7.20.5" "@types/babel__helper-plugin-utils": "npm:^7.10.3" "@types/babel__traverse": "npm:^7.20.6" tslib: "npm:^2.8.0" - checksum: 10c0/08ddc1516e6504bc794257cc7a5b788068afce5f0bdb1c98458d6e7eb9e5b96385f5f4912f92909aad72b4e20083c1472e16d7c05d008bd84a5f3a6d38bb1e95 + checksum: 10c0/b065cc92ae70dd237bc2aa151f52f91f06337b2ad268d9332cf257414da528f8376aabe7019f9ff630abf55c1b409ecbac720a6ea163397179133b244982320e languageName: node linkType: hard @@ -8455,15 +8379,15 @@ __metadata: languageName: node linkType: hard -"intl-messageformat@npm:10.7.16, intl-messageformat@npm:^10.7.16": - version: 10.7.16 - resolution: "intl-messageformat@npm:10.7.16" +"intl-messageformat@npm:10.7.17, intl-messageformat@npm:^10.7.16": + version: 10.7.17 + resolution: "intl-messageformat@npm:10.7.17" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" + "@formatjs/ecma402-abstract": "npm:2.3.5" "@formatjs/fast-memoize": "npm:2.2.7" - "@formatjs/icu-messageformat-parser": "npm:2.11.2" + "@formatjs/icu-messageformat-parser": "npm:2.11.3" tslib: "npm:^2.8.0" - checksum: 10c0/537735bf6439f0560f132895d117df6839957ac04cdd58d861f6da86803d40bfc19059e3d341ddb8de87214b73a6329b57f9acdb512bb0f745dcf08729507b9b + checksum: 10c0/74445987da233cd5a6df0e4d35813ff11483b0788ff5cbbcf9e2a07c276cdd18001a3ba68583bf8d28920494b68a2be91a9c9f796062973dec3c95a4e88ffa81 languageName: node linkType: hard @@ -9078,16 +9002,16 @@ __metadata: languageName: node linkType: hard -"json-stable-stringify@npm:^1.1.1": - version: 1.2.1 - resolution: "json-stable-stringify@npm:1.2.1" +"json-stable-stringify@npm:^1.1.1, json-stable-stringify@npm:^1.3.0": + version: 1.3.0 + resolution: "json-stable-stringify@npm:1.3.0" dependencies: call-bind: "npm:^1.0.8" - call-bound: "npm:^1.0.3" + call-bound: "npm:^1.0.4" isarray: "npm:^2.0.5" jsonify: "npm:^0.0.1" object-keys: "npm:^1.1.1" - checksum: 10c0/e623e7ce89282f089d56454087edb717357e8572089b552fbc6980fb7814dc3943f7d0e4f1a19429a36ce9f4428b6c8ee6883357974457aaaa98daba5adebeea + checksum: 10c0/8b3ff19e4c23c0ad591a49bc3a015d89a538db787d12fe9c4072e1d64d8cfa481f8c37719c629c3d84e848847617bf49f5fee894cf1d25959ab5b67e1c517f31 languageName: node linkType: hard @@ -11279,16 +11203,16 @@ __metadata: linkType: hard "react-intl@npm:^7.1.10": - version: 7.1.11 - resolution: "react-intl@npm:7.1.11" + version: 7.1.13 + resolution: "react-intl@npm:7.1.13" dependencies: - "@formatjs/ecma402-abstract": "npm:2.3.4" - "@formatjs/icu-messageformat-parser": "npm:2.11.2" - "@formatjs/intl": "npm:3.1.6" + "@formatjs/ecma402-abstract": "npm:2.3.5" + "@formatjs/icu-messageformat-parser": "npm:2.11.3" + "@formatjs/intl": "npm:3.1.7" "@types/hoist-non-react-statics": "npm:^3.3.1" "@types/react": "npm:16 || 17 || 18 || 19" hoist-non-react-statics: "npm:^3.3.2" - intl-messageformat: "npm:10.7.16" + intl-messageformat: "npm:10.7.17" tslib: "npm:^2.8.0" peerDependencies: react: 16 || 17 || 18 || 19 @@ -11296,7 +11220,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10c0/f20770fb7bcce7a67acec70b9183f5320b7f3f9bbcb263ca8f4787817297674d1be158687f94d1e2803a9c8696d4f93dd86a28898aba8bc5197e858313e3dd06 + checksum: 10c0/2288c63c3eb2b3fdb79af5fa7b0d297469cc2530143fec798d4603d713c2caf28c9cc1e3a0a53c7b0f90331a3b672ae18e0edefd2fe816de90f90daa7612fb41 languageName: node linkType: hard @@ -13557,22 +13481,7 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:^8.28.0": - version: 8.38.0 - resolution: "typescript-eslint@npm:8.38.0" - dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.38.0" - "@typescript-eslint/parser": "npm:8.38.0" - "@typescript-eslint/typescript-estree": "npm:8.38.0" - "@typescript-eslint/utils": "npm:8.38.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/486b9862ee08f7827d808a2264ce03b58087b11c4c646c0da3533c192a67ae3fcb4e68d7a1e69d0f35a1edc274371a903a50ecfe74012d5eaa896cb9d5a81e0b - languageName: node - linkType: hard - -"typescript-eslint@npm:^8.45.0": +"typescript-eslint@npm:^8.28.0, typescript-eslint@npm:^8.45.0": version: 8.45.0 resolution: "typescript-eslint@npm:8.45.0" dependencies: @@ -13587,6 +13496,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.8.3": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 + languageName: node + linkType: hard + "typescript@npm:^5.6.0, typescript@npm:~5.9.0": version: 5.9.2 resolution: "typescript@npm:5.9.2" @@ -13597,6 +13516,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A5.8.3#optional!builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A^5.6.0#optional!builtin, typescript@patch:typescript@npm%3A~5.9.0#optional!builtin": version: 5.9.2 resolution: "typescript@patch:typescript@npm%3A5.9.2#optional!builtin::version=5.9.2&hash=5786d5" From e4c3854ae8332bc3273e2949b050bd25205c5404 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 7 Oct 2025 18:43:40 +0200 Subject: [PATCH 19/44] Ensure Fetch-all-replies snackbar is shown at the bottom of the screen (#36383) --- app/javascript/mastodon/features/status/index.jsx | 2 +- app/javascript/styles/mastodon/components.scss | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 7c38af3277..ff32d63e87 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -603,7 +603,7 @@ class Status extends ImmutablePureComponent { /> -
+
{ancestors} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index bf518d72fc..7e677b8ef4 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3153,18 +3153,23 @@ a.account__display-name { .column__alert { position: sticky; - bottom: 1rem; + bottom: 0; z-index: 10; box-sizing: border-box; display: grid; width: 100%; max-width: 360px; - padding-inline: 10px; - margin-top: 1rem; - margin-inline: auto; + padding: 1rem; + margin: auto auto 0; + overflow: clip; + + &:empty { + padding: 0; + } @media (max-width: #{$mobile-menu-breakpoint - 1}) { - bottom: 4rem; + // Compensate for mobile menubar + bottom: var(--mobile-bottom-nav-height); } & > * { From 092f46f61a2e4547a05e4388ab5855688e0da187 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 8 Oct 2025 04:21:23 -0400 Subject: [PATCH 20/44] Use bundler version 2.7.2 (#36367) --- Gemfile.lock | 54 +++++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d67384d8e7..8a8bca58a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,7 +96,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1135.0) + aws-partitions (1.1168.0) aws-sdk-core (3.215.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -207,7 +207,7 @@ GEM railties (>= 5) dotenv (3.1.8) drb (2.2.3) - dry-cli (1.2.0) + dry-cli (1.3.0) elasticsearch (7.17.11) elasticsearch-api (= 7.17.11) elasticsearch-transport (= 7.17.11) @@ -226,18 +226,18 @@ GEM activemodel erb (5.0.2) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo - excon (1.2.8) + excon (1.3.0) logger fabrication (3.0.0) faker (3.5.2) i18n (>= 1.8.11, < 2) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger - faraday-follow_redirects (0.3.0) + faraday-follow_redirects (0.4.0) faraday (>= 1, < 3) faraday-httpclient (2.0.2) httpclient (>= 2.2) @@ -266,18 +266,19 @@ GEM fog-openstack (1.1.5) fog-core (~> 2.1) fog-json (>= 1.0) - formatador (1.1.1) + formatador (1.2.1) + reline forwardable (1.3.3) - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + fugit (1.12.0) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.31.1) + google-protobuf (4.32.1) bigdecimal rake (>= 13) - googleapis-common-protos-types (1.20.0) - google-protobuf (>= 3.18, < 5.a) + googleapis-common-protos-types (1.21.0) + google-protobuf (~> 4.26) haml (6.3.0) temple (>= 0.8.2) thor @@ -293,7 +294,7 @@ GEM rainbow rubocop (>= 1.0) sysexits (~> 1.1) - hashdiff (1.2.0) + hashdiff (1.2.1) hashie (5.0.0) hcaptcha (7.1.0) json @@ -309,7 +310,7 @@ GEM http-cookie (~> 1.0) http-form_data (~> 2.2) llhttp-ffi (~> 0.5.0) - http-cookie (1.0.8) + http-cookie (1.1.0) domain_name (~> 0.5) http-form_data (2.3.0) http_accept_language (2.1.1) @@ -347,7 +348,7 @@ GEM jmespath (1.6.2) json (2.15.0) json-canonicalization (1.0.0) - json-jwt (1.16.7) + json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap base64 @@ -438,7 +439,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2025.0916) + mime-types-data (3.2025.0924) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) @@ -447,7 +448,7 @@ GEM mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-ldap (0.20.0) @@ -497,7 +498,7 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.0) + openssl (3.3.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) opentelemetry-api (1.7.0) @@ -510,8 +511,8 @@ GEM opentelemetry-common (~> 0.20) opentelemetry-sdk (~> 1.2) opentelemetry-semantic_conventions - opentelemetry-helpers-sql (0.1.1) - opentelemetry-api (~> 1.0) + opentelemetry-helpers-sql (0.2.0) + opentelemetry-api (~> 1.7) opentelemetry-helpers-sql-obfuscation (0.3.0) opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.4.0) @@ -616,7 +617,7 @@ GEM playwright-ruby-client (1.55.0) concurrent-ruby (>= 1.1.6) mime-types (>= 3.0) - pp (0.6.2) + pp (0.6.3) prettyprint premailer (1.27.0) addressable @@ -714,9 +715,10 @@ GEM readline (~> 0.0) rdf-normalize (0.7.0) rdf (~> 3.3) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort readline (0.0.4) reline redcarpet (3.6.1) @@ -733,7 +735,7 @@ GEM railties (>= 5.2) rexml (3.4.4) rotp (6.3.0) - rouge (4.6.0) + rouge (4.6.1) rpam2 (4.0.2) rqrcode (3.1.0) chunky_png (~> 1.0) @@ -766,7 +768,7 @@ GEM rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) - rspec-support (3.13.4) + rspec-support (3.13.6) rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -1110,4 +1112,4 @@ RUBY VERSION ruby 3.4.1p0 BUNDLED WITH - 2.7.1 + 2.7.2 From 4fce4337d81a7e591b292caeb9ba049d094b9350 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Wed, 8 Oct 2025 11:35:36 +0200 Subject: [PATCH 21/44] Update `rack` and `uri` to the latest release (#36389) --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8a8bca58a8..5d41362c69 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -645,7 +645,7 @@ GEM activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (3.2.1) + rack (3.2.2) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (3.0.0) @@ -907,7 +907,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) validate_url (1.0.15) activemodel (>= 3.0.0) From 3867f3bc61e46be52fa6c40bea928694e7be5cc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 12:06:30 +0200 Subject: [PATCH 22/44] New Crowdin Translations (automated) (#36386) Co-authored-by: GitHub Actions --- config/locales/be.yml | 2 +- config/locales/hu.yml | 4 ++++ config/locales/nl.yml | 4 ++++ config/locales/simple_form.be.yml | 8 ++++++-- config/locales/simple_form.es-AR.yml | 8 ++++---- config/locales/simple_form.hu.yml | 4 ++++ 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/config/locales/be.yml b/config/locales/be.yml index 20defa85d2..a120dcefb5 100644 --- a/config/locales/be.yml +++ b/config/locales/be.yml @@ -707,7 +707,7 @@ be: created_at: Створана delete_and_resolve: Выдаліць допісы forwarded: Пераслана - forwarded_replies_explanation: Гэтае паведамленне паступіла ад выдаленага карыстальніка і дакранаецца выдаленага змесціва. Яно было накіраванае вам, бо змесціва паведамлення з'яўляецца адказам аднаму з вашых карыстальнікаў. + forwarded_replies_explanation: Гэтае паведамленне паступіла ад карыстальніка з іншага сервера і дакранаецца змесціва адтуль. Яно было накіраванае вам, бо змесціва паведамлення з'яўляецца адказам аднаму з вашых карыстальнікаў. forwarded_to: Пераслана на %{domain} mark_as_resolved: Пазначыць як вырашаную mark_as_sensitive: Пазначыць як далікатны diff --git a/config/locales/hu.yml b/config/locales/hu.yml index 123e8a97c9..2b57ef5516 100644 --- a/config/locales/hu.yml +++ b/config/locales/hu.yml @@ -848,6 +848,10 @@ hu: all: Mindenkinek disabled: Senkinek users: Bejelentkezett helyi felhasználóknak + feed_access: + modes: + authenticated: Csak hitelesített felhasználók + public: Mindenki registrations: moderation_recommandation: Győződj meg arról, hogy megfelelő és gyors reagálású moderátor csapatod van, mielőtt mindenki számára megnyitod a regisztrációt! preamble: Szabályozd, hogy ki hozhat létre fiókot a kiszolgálón. diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 0c557cd442..fad7cf91aa 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -848,6 +848,10 @@ nl: all: Aan iedereen disabled: Aan niemand users: Aan ingelogde lokale gebruikers + feed_access: + modes: + authenticated: Alleen geverifieerde gebruikers + public: Iedereen registrations: moderation_recommandation: Zorg ervoor dat je een adequaat en responsief moderatieteam hebt voordat je registraties voor iedereen openstelt! preamble: Toezicht houden op wie een account op deze server kan registreren. diff --git a/config/locales/simple_form.be.yml b/config/locales/simple_form.be.yml index ceac57996d..eaa6d52708 100644 --- a/config/locales/simple_form.be.yml +++ b/config/locales/simple_form.be.yml @@ -91,7 +91,7 @@ be: custom_css: Вы можаце прымяняць карыстальніцкія стылі ў вэб-версіі Mastodon. favicon: WEBP, PNG, GIF ці JPG. Замяняе прадвызначаны favicon Mastodon на ўласны значок. mascot: Замяняе ілюстрацыю ў пашыраным вэб-інтэрфейсе. - media_cache_retention_period: Медыяфайлы з допісаў, зробленых выдаленымі карыстальнікамі, кэшыруюцца на вашым серверы. Пры станоўчым значэнні медыяфайлы будуць выдалены праз пазначаную колькасць дзён. Калі медыяданыя будуць запытаны пасля выдалення, яны будуць спампаваны зноў, калі зыходнае змесціва усё яшчэ даступнае. У сувязі з абмежаваннямі на частату абнаўлення картак перадпрагляду іншых сайтаў, рэкамендуецца ўсталяваць значэнне не менш за 14 дзён, інакш гэтыя карткі не будуць абнаўляцца па запыце раней за гэты тэрмін. + media_cache_retention_period: Медыяфайлы з допісаў, зробленых карыстальнікамі з іншых сервераў, кэшыруюцца на вашым серверы. Пры станоўчым значэнні медыяфайлы будуць выдалены праз пазначаную колькасць дзён. Калі медыяданыя будуць запытаныя пасля выдалення, яны будуць спампаваныя зноў, калі зыходнае змесціва усё яшчэ даступнае. У сувязі з абмежаваннямі на частату абнаўлення картак перадпрагляду іншых сайтаў, рэкамендуецца ўсталяваць значэнне не менш за 14 дзён, інакш гэтыя карткі не будуць абнаўляцца па запыце раней за гэты тэрмін. min_age: Карыстальнікі будуць атрымліваць запыт на пацвярджэнне даты нараджэння падчас рэгістрацыі peers_api_enabled: Спіс даменных імён, з якімі сутыкнуўся гэты сервер у федэральным сусвеце. Даныя пра тое, ці знаходзіцеся вы з дадзеным серверам у федэрацыі, не ўключаны. Уключаны толькі даныя пра тое, што ваш сервер ведае пра іншыя серверы. Гэта выкарыстоўваецца сэрвісамі, якія збіраюць статыстыку па федэрацыі ў агульным сэнсе. profile_directory: Дырэкторыя профіляў змяшчае спіс усіх карыстальнікаў, якія вырашылі быць бачнымі. @@ -282,15 +282,19 @@ be: backups_retention_period: Працягласць захавання архіву карыстальніка bootstrap_timeline_accounts: Заўсёды раіць гэтыя ўліковыя запісы новым карыстальнікам closed_registrations_message: Уласнае паведамленне, калі рэгістрацыя немагчымая - content_cache_retention_period: Перыяд захоўвання выдаленага змесціва + content_cache_retention_period: Перыяд захоўвання змесціва з іншых сервераў custom_css: CSS карыстальніка favicon: Значок сайта + local_live_feed_access: Доступ да жывых стужак з лакальнымі допісамі + local_topic_feed_access: Доступ да хэштэгавых і спасылачных стужак з лакальнымі допісамі mascot: Уласны маскот(спадчына) media_cache_retention_period: Працягласць захавання кэшу для медыя min_age: Патрабаванне мінімальнага ўзросту peers_api_enabled: Апублікаваць спіс знойдзеных сервераў у API profile_directory: Уключыць каталог профіляў registrations_mode: Хто можа зарэгістравацца + remote_live_feed_access: Доступ да жывых стужак з допісамі з іншых сервераў + remote_topic_feed_access: Доступ да хэштэгавых і спасылачных стужак з допісамі з іншых сервераў require_invite_text: Каб далучыцца, патрэбна прычына show_domain_blocks: Паказаць заблакіраваныя дамены show_domain_blocks_rationale: Паказваць прычыну блакавання даменаў diff --git a/config/locales/simple_form.es-AR.yml b/config/locales/simple_form.es-AR.yml index 502a69ada9..d65a3ef1fd 100644 --- a/config/locales/simple_form.es-AR.yml +++ b/config/locales/simple_form.es-AR.yml @@ -283,16 +283,16 @@ es-AR: content_cache_retention_period: Período de retención de contenido remoto custom_css: CSS personalizado favicon: Favicón - local_live_feed_access: Acceso a las cronologías que destacan publicaciones locales - local_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones locales + local_live_feed_access: Acceso a líneas temporales en vivo, destacando mensajes locales + local_topic_feed_access: Acceso a líneas temporales de etiquetas y enlaces, destacando mensajes locales mascot: Mascota personalizada (legado) media_cache_retention_period: Período de retención de la caché de medios min_age: Edad mínima requerida peers_api_enabled: Publicar lista de servidores descubiertos en la API profile_directory: Habilitar directorio de perfiles registrations_mode: Quién puede registrarse - remote_live_feed_access: Acceso a las cronologías que destacan publicaciones remotas - remote_topic_feed_access: Acceso a las etiquetas y enlaces en tendencia que destacan publicaciones remotas + remote_live_feed_access: Acceso a líneas temporales en vivo, destacando mensajes remotos + remote_topic_feed_access: Acceso a líneas temporales de etiquetas y enlaces, destacando mensajes remotos require_invite_text: Requerir un motivo para unirse show_domain_blocks: Mostrar dominios bloqueados show_domain_blocks_rationale: Mostrar por qué se bloquearon los dominios diff --git a/config/locales/simple_form.hu.yml b/config/locales/simple_form.hu.yml index 6d50bb347b..622fb0bbe9 100644 --- a/config/locales/simple_form.hu.yml +++ b/config/locales/simple_form.hu.yml @@ -283,12 +283,16 @@ hu: content_cache_retention_period: Távoli tartalmak megtartási időszaka custom_css: Egyéni CSS favicon: Könyvjelzőikon + local_live_feed_access: Helyi bejegyzéseket bemutató élő hírfolyamok elérése + local_topic_feed_access: Helyi bejegyzéseket bemutató hashtagek és hivatkozásfolyamok elérése mascot: Egyéni kabala (örökölt) media_cache_retention_period: Média-gyorsítótár megtartási időszaka min_age: Minimális korhatár peers_api_enabled: Felfedezett kiszolgálók listájának közzététele az API-ban profile_directory: Profiladatbázis engedélyezése registrations_mode: Ki regisztrálhat + remote_live_feed_access: Távoli bejegyzéseket bemutató élő hírfolyamok elérése + remote_topic_feed_access: Távoli bejegyzéseket bemutató hashtagek és hivatkozásfolyamok elérése require_invite_text: Indok megkövetelése a csatlakozáshoz show_domain_blocks: Domain tiltások megjelenitése show_domain_blocks_rationale: A domainek letiltási okainak megjelenítése From 6abda76d13b46c82741de8618e2c141b29fe5355 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 13:11:25 +0200 Subject: [PATCH 23/44] Emoji: Account page (#36385) --- .../mastodon/components/account/index.tsx | 6 +- .../mastodon/components/account_bio.tsx | 27 ++----- .../mastodon/components/account_fields.tsx | 70 +++++++++++----- .../mastodon/components/emoji/html.tsx | 16 +++- .../components/hover_card_account.tsx | 13 ++- .../components/status/handled_link.tsx | 31 +++++++ .../mastodon/components/verified_badge.tsx | 31 ++++++- .../components/account_header.tsx | 51 +----------- app/javascript/mastodon/hooks/useLinks.ts | 7 ++ app/javascript/mastodon/utils/html.ts | 80 ++++++++++--------- 10 files changed, 195 insertions(+), 137 deletions(-) diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index 8397695a44..3aebedc949 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { blockAccount, @@ -331,9 +332,10 @@ export const Account: React.FC = ({ {account && withBio && (account.note.length > 0 ? ( -
) : (
diff --git a/app/javascript/mastodon/components/account_bio.tsx b/app/javascript/mastodon/components/account_bio.tsx index 6c9ea43a40..e87ae654fd 100644 --- a/app/javascript/mastodon/components/account_bio.tsx +++ b/app/javascript/mastodon/components/account_bio.tsx @@ -6,10 +6,9 @@ import { useLinks } from 'mastodon/hooks/useLinks'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; -import type { OnElementHandler } from '../utils/html'; import { EmojiHTML } from './emoji/html'; -import { HandledLink } from './status/handled_link'; +import { useElementHandledLink } from './status/handled_link'; interface AccountBioProps { className: string; @@ -38,23 +37,9 @@ export const AccountBio: React.FC = ({ [showDropdown, accountId], ); - const handleLink = useCallback( - (element, { key, ...props }) => { - if (element instanceof HTMLAnchorElement) { - return ( - - ); - } - return undefined; - }, - [accountId], - ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: showDropdown ? accountId : undefined, + }); const note = useAppSelector((state) => { const account = state.accounts.get(accountId); @@ -77,9 +62,9 @@ export const AccountBio: React.FC = ({ htmlString={note} extraEmojis={extraEmojis} className={classNames(className, 'translate')} - onClickCapture={isModernEmojiEnabled() ? undefined : handleClick} + onClickCapture={handleClick} ref={handleNodeChange} - onElement={handleLink} + {...htmlHandlers} /> ); }; diff --git a/app/javascript/mastodon/components/account_fields.tsx b/app/javascript/mastodon/components/account_fields.tsx index 4ce55f7896..dd17b89d86 100644 --- a/app/javascript/mastodon/components/account_fields.tsx +++ b/app/javascript/mastodon/components/account_fields.tsx @@ -1,42 +1,70 @@ +import { useIntl } from 'react-intl'; + import classNames from 'classnames'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { Icon } from 'mastodon/components/icon'; -import { useLinks } from 'mastodon/hooks/useLinks'; import type { Account } from 'mastodon/models/account'; -export const AccountFields: React.FC<{ - fields: Account['fields']; - limit: number; -}> = ({ fields, limit = -1 }) => { - const handleClick = useLinks(); +import { CustomEmojiProvider } from './emoji/context'; +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; + +export const AccountFields: React.FC> = ({ + fields, + emojis, +}) => { + const intl = useIntl(); + const htmlHandlers = useElementHandledLink(); if (fields.size === 0) { return null; } return ( -
- {fields.take(limit).map((pair, i) => ( -
-
+ {fields.map((pair, i) => ( +
+ -
- {pair.get('verified_at') && ( - - )} - + {pair.verified_at && ( + + + + )}{' '} +
))} -
+ ); }; + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index 73ad5fa233..b462a2ee6f 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -4,7 +4,10 @@ import classNames from 'classnames'; import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types'; import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; -import type { OnElementHandler } from '@/mastodon/utils/html'; +import type { + OnAttributeHandler, + OnElementHandler, +} from '@/mastodon/utils/html'; import { htmlStringToComponents } from '@/mastodon/utils/html'; import { polymorphicForwardRef } from '@/types/polymorphic'; @@ -16,6 +19,7 @@ interface EmojiHTMLProps { extraEmojis?: CustomEmojiMapArg; className?: string; onElement?: OnElementHandler; + onAttribute?: OnAttributeHandler; } export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( @@ -26,14 +30,19 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( as: asProp = 'div', // Rename for syntax highlighting className = '', onElement, + onAttribute, ...props }, ref, ) => { const contents = useMemo( () => - htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }), - [htmlString, onElement], + htmlStringToComponents(htmlString, { + onText: textToEmojis, + onElement, + onAttribute, + }), + [htmlString, onAttribute, onElement], ); return ( @@ -60,6 +69,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( extraEmojis, className, onElement, + onAttribute, ...rest } = props; const Wrapper = asElement ?? 'div'; diff --git a/app/javascript/mastodon/components/hover_card_account.tsx b/app/javascript/mastodon/components/hover_card_account.tsx index a5a5e4c957..471d488415 100644 --- a/app/javascript/mastodon/components/hover_card_account.tsx +++ b/app/javascript/mastodon/components/hover_card_account.tsx @@ -23,6 +23,8 @@ import { domain } from 'mastodon/initial_state'; import { getAccountHidden } from 'mastodon/selectors/accounts'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; +import { useLinks } from '../hooks/useLinks'; + export const HoverCardAccount = forwardRef< HTMLDivElement, { accountId?: string } @@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef< !isMutual && !isFollower; + const handleClick = useLinks(); + return (
- + +
+ +
+ {note && note.length > 0 && (
diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index ee41321283..d403038182 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -1,7 +1,10 @@ +import { useCallback } from 'react'; import type { ComponentProps, FC } from 'react'; import { Link } from 'react-router-dom'; +import type { OnElementHandler } from '@/mastodon/utils/html'; + export interface HandledLinkProps { href: string; text: string; @@ -77,3 +80,31 @@ export const HandledLink: FC> = ({ return text; } }; + +export const useElementHandledLink = ({ + hashtagAccountId, + mentionAccountId, +}: { + hashtagAccountId?: string; + mentionAccountId?: string; +} = {}) => { + const onElement = useCallback( + (element, { key, ...props }) => { + if (element instanceof HTMLAnchorElement) { + return ( + + ); + } + return undefined; + }, + [hashtagAccountId, mentionAccountId], + ); + return { onElement }; +}; diff --git a/app/javascript/mastodon/components/verified_badge.tsx b/app/javascript/mastodon/components/verified_badge.tsx index 626cc500d6..43edbc7951 100644 --- a/app/javascript/mastodon/components/verified_badge.tsx +++ b/app/javascript/mastodon/components/verified_badge.tsx @@ -1,10 +1,17 @@ +import { EmojiHTML } from '@/mastodon/components/emoji/html'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { isModernEmojiEnabled } from '../utils/environment'; +import type { OnAttributeHandler } from '../utils/html'; + import { Icon } from './icon'; const domParser = new DOMParser(); const stripRelMe = (html: string) => { + if (isModernEmojiEnabled()) { + return html; + } const document = domParser.parseFromString(html, 'text/html').documentElement; document.querySelectorAll('a[rel]').forEach((link) => { @@ -15,7 +22,23 @@ const stripRelMe = (html: string) => { }); const body = document.querySelector('body'); - return body ? { __html: body.innerHTML } : undefined; + return body?.innerHTML ?? ''; +}; + +const onAttribute: OnAttributeHandler = (name, value, tagName) => { + if (name === 'rel' && tagName === 'a') { + if (value === 'me') { + return null; + } + return [ + name, + value + .split(' ') + .filter((x) => x !== 'me') + .join(' '), + ]; + } + return undefined; }; interface Props { @@ -24,6 +47,10 @@ interface Props { export const VerifiedBadge: React.FC = ({ link }) => ( - + ); 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 776157ccf5..2bf636d060 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet'; import { NavLink } from 'react-router-dom'; import { AccountBio } from '@/mastodon/components/account_bio'; +import { AccountFields } from '@/mastodon/components/account_fields'; 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'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; @@ -186,14 +186,6 @@ const titleFromAccount = (account: Account) => { return `${prefix} (@${acct})`; }; -const dateFormatOptions: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', -}; - export const AccountHeader: React.FC<{ accountId: string; hideTabs?: boolean; @@ -891,46 +883,7 @@ export const AccountHeader: React.FC<{
- {fields.map((pair, i) => ( -
-
- -
- {pair.verified_at && ( - - - - )}{' '} - -
-
- ))} +
diff --git a/app/javascript/mastodon/hooks/useLinks.ts b/app/javascript/mastodon/hooks/useLinks.ts index 00e1dd9bb4..77609181be 100644 --- a/app/javascript/mastodon/hooks/useLinks.ts +++ b/app/javascript/mastodon/hooks/useLinks.ts @@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit'; import { openURL } from 'mastodon/actions/search'; import { useAppDispatch } from 'mastodon/store'; +import { isModernEmojiEnabled } from '../utils/environment'; + const isMentionClick = (element: HTMLAnchorElement) => element.classList.contains('mention') && !element.classList.contains('hashtag'); @@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => { const handleClick = useCallback( (e: React.MouseEvent) => { + // Exit early if modern emoji is enabled, as this is handled by HandledLink. + if (isModernEmojiEnabled()) { + return; + } + const target = (e.target as HTMLElement).closest('a'); if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) { diff --git a/app/javascript/mastodon/utils/html.ts b/app/javascript/mastodon/utils/html.ts index f37018d86d..c87b5a34cf 100644 --- a/app/javascript/mastodon/utils/html.ts +++ b/app/javascript/mastodon/utils/html.ts @@ -41,18 +41,22 @@ export type OnElementHandler< extra: Arg, ) => React.ReactNode; +export type OnAttributeHandler< + Arg extends Record = Record, +> = ( + name: string, + value: string, + tagName: string, + extra: Arg, +) => [string, unknown] | undefined | null; + export interface HTMLToStringOptions< Arg extends Record = Record, > { maxDepth?: number; onText?: (text: string, extra: Arg) => React.ReactNode; onElement?: OnElementHandler; - onAttribute?: ( - name: string, - value: string, - tagName: string, - extra: Arg, - ) => [string, unknown] | null; + onAttribute?: OnAttributeHandler; allowedTags?: AllowedTagsType; extraArgs?: Arg; } @@ -140,44 +144,44 @@ export function htmlStringToComponents>( // Custom attribute handler. if (onAttribute) { - const result = onAttribute( - name, - attr.value, - node.tagName.toLowerCase(), - extraArgs, - ); + const result = onAttribute(name, attr.value, tagName, extraArgs); + // Rewrite this attribute. if (result) { const [cbName, value] = result; props[cbName] = value; - } - } else { - // Check global attributes first, then tag-specific ones. - const globalAttr = globalAttributes[name]; - const tagAttr = tagInfo.attributes?.[name]; - - // Exit if neither global nor tag-specific attribute is allowed. - if (!globalAttr && !tagAttr) { + continue; + } else if (result === null) { + // Explicitly remove this attribute. continue; } - - // Rename if needed. - if (typeof tagAttr === 'string') { - name = tagAttr; - } else if (typeof globalAttr === 'string') { - name = globalAttr; - } - - let value: string | boolean | number = attr.value; - - // Handle boolean attributes. - if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; - } - - props[name] = value; } + + // Check global attributes first, then tag-specific ones. + const globalAttr = globalAttributes[name]; + const tagAttr = tagInfo.attributes?.[name]; + + // Exit if neither global nor tag-specific attribute is allowed. + if (!globalAttr && !tagAttr) { + continue; + } + + // Rename if needed. + if (typeof tagAttr === 'string') { + name = tagAttr; + } else if (typeof globalAttr === 'string') { + name = globalAttr; + } + + let value: string | boolean | number = attr.value; + + // Handle boolean attributes. + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } + + props[name] = value; } // If onElement is provided, use it to create the element. From 987f1e897b49a7f5834b5efd07d8f0230b731d8e Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 8 Oct 2025 14:31:51 +0200 Subject: [PATCH 24/44] Fix JSON payload being potentially mutated when processing interaction policies (#36392) --- app/lib/activitypub/parser/status_parser.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index ad4ecebbbf..7439cca5b2 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -142,7 +142,7 @@ class ActivityPub::Parser::StatusParser def quote_subpolicy(subpolicy) flags = 0 - allowed_actors = as_array(subpolicy) + allowed_actors = as_array(subpolicy).dup allowed_actors.uniq! flags |= Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] if allowed_actors.delete('as:Public') || allowed_actors.delete('Public') || allowed_actors.delete('https://www.w3.org/ns/activitystreams#Public') From d8f0326b02e9127ba6fea7a969efe85fd946a58e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:39:24 +0200 Subject: [PATCH 25/44] Update dependency sidekiq to v8.0.8 (#36388) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5d41362c69..f01f36354a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -346,7 +346,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.15.0) + json (2.15.1) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -829,7 +829,7 @@ GEM securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) - sidekiq (8.0.7) + sidekiq (8.0.8) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) From 0be0a8898a8837ac7cf235020044b608596df271 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 8 Oct 2025 14:56:32 +0200 Subject: [PATCH 26/44] Fix Update/Delete of quoted status not being forwarded to quoters's followers (#36390) --- app/lib/activitypub/forwarder.rb | 16 +++++-- spec/lib/activitypub/forwarder_spec.rb | 61 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 spec/lib/activitypub/forwarder_spec.rb diff --git a/app/lib/activitypub/forwarder.rb b/app/lib/activitypub/forwarder.rb index 3a94f9669a..c5ff59fa5a 100644 --- a/app/lib/activitypub/forwarder.rb +++ b/app/lib/activitypub/forwarder.rb @@ -27,17 +27,25 @@ class ActivityPub::Forwarder @reblogged_by_account_ids ||= @status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id) end + def quoted_by_account_ids + @quoted_by_account_ids ||= @status.quotes.includes(:account).references(:account).merge(Account.local).pluck(:account_id) + end + + def shared_by_account_ids + reblogged_by_account_ids.concat(quoted_by_account_ids) + end + def signature_account_id @signature_account_id ||= if in_reply_to_local? in_reply_to.account_id else - reblogged_by_account_ids.first + shared_by_account_ids.first end end def inboxes @inboxes ||= begin - arr = inboxes_for_followers_of_reblogged_by_accounts + arr = inboxes_for_followers_of_shared_by_accounts arr += inboxes_for_followers_of_replied_to_account if in_reply_to_local? arr -= [@account.preferred_inbox_url] arr.uniq! @@ -45,8 +53,8 @@ class ActivityPub::Forwarder end end - def inboxes_for_followers_of_reblogged_by_accounts - Account.where(id: ::Follow.where(target_account_id: reblogged_by_account_ids).select(:account_id)).inboxes + def inboxes_for_followers_of_shared_by_accounts + Account.where(id: ::Follow.where(target_account_id: shared_by_account_ids).select(:account_id)).inboxes end def inboxes_for_followers_of_replied_to_account diff --git a/spec/lib/activitypub/forwarder_spec.rb b/spec/lib/activitypub/forwarder_spec.rb new file mode 100644 index 0000000000..f72e334218 --- /dev/null +++ b/spec/lib/activitypub/forwarder_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::Forwarder do + subject { described_class.new(account, payload, status) } + + let(:account) { Fabricate(:account) } + let(:remote_account) { Fabricate(:account, domain: 'example.com') } + let(:status) { Fabricate(:status, account: remote_account) } + + let(:signature) { { type: 'RsaSignature2017', signatureValue: 'foo' } } + let(:payload) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', + ], + signature: signature, + type: 'Delete', + object: ActivityPub::TagManager.instance.uri_for(status), + }.deep_stringify_keys + end + + describe '#forwardable?' do + context 'when payload has an inlined signature' do + it 'returns true' do + expect(subject.forwardable?).to be true + end + end + + context 'when payload has an no inlined signature' do + let(:signature) { nil } + + it 'returns true' do + expect(subject.forwardable?).to be false + end + end + end + + describe '#forward!' do + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:eve) { Fabricate(:account, domain: 'remote1.example.com', inbox_url: 'https://remote1.example.com/users/eve/inbox', protocol: :activitypub) } + let(:mallory) { Fabricate(:account, domain: 'remote2.example.com', inbox_url: 'https://remote2.example.com/users/mallory/inbox', protocol: :activitypub) } + + before do + alice.statuses.create!(reblog: status) + Fabricate(:quote, status: Fabricate(:status, account: bob), quoted_status: status, state: :accepted) + + eve.follow!(alice) + mallory.follow!(bob) + end + + it 'correctly forwards to expected remote followers' do + expect { subject.forward! } + .to enqueue_sidekiq_job(ActivityPub::LowPriorityDeliveryWorker).with(Oj.dump(payload), anything, eve.preferred_inbox_url) + .and enqueue_sidekiq_job(ActivityPub::LowPriorityDeliveryWorker).with(Oj.dump(payload), anything, mallory.preferred_inbox_url) + end + end +end From 4fd5b6e73b5fa3ae6c4f55467c8633ef51f8cb98 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:15:22 +0200 Subject: [PATCH 27/44] Update opentelemetry-ruby (non-major) (#36313) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile | 26 +++++----- Gemfile.lock | 135 +++++++++++++++++++++------------------------------ 2 files changed, 68 insertions(+), 93 deletions(-) diff --git a/Gemfile b/Gemfile index 0d9ab34271..aa201d1e72 100644 --- a/Gemfile +++ b/Gemfile @@ -106,19 +106,19 @@ gem 'opentelemetry-api', '~> 1.7.0' group :opentelemetry do gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false - gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false - gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false - gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false - gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false - gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false + gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false + gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false + gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false end diff --git a/Gemfile.lock b/Gemfile.lock index f01f36354a..38e189a21b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -515,82 +515,57 @@ GEM opentelemetry-api (~> 1.7) opentelemetry-helpers-sql-obfuscation (0.3.0) opentelemetry-common (~> 0.21) - opentelemetry-instrumentation-action_mailer (0.4.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_mailer (0.5.0) opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-action_pack (0.13.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-action_pack (0.14.1) opentelemetry-instrumentation-rack (~> 0.21) - opentelemetry-instrumentation-action_view (0.9.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-action_view (0.10.0) opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_job (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_model_serializers (0.22.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_job (0.9.2) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-active_model_serializers (0.23.0) opentelemetry-instrumentation-active_support (>= 0.7.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_record (0.9.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_storage (0.1.1) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_record (0.10.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-active_storage (0.2.0) opentelemetry-instrumentation-active_support (~> 0.7) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-active_support (0.8.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-base (0.23.0) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-active_support (0.9.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-base (0.24.0) + opentelemetry-api (~> 1.7) opentelemetry-common (~> 0.21) opentelemetry-registry (~> 0.1) - opentelemetry-instrumentation-concurrent_ruby (0.22.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-excon (0.24.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-faraday (0.28.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-http (0.25.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-http_client (0.24.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-net_http (0.24.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-pg (0.30.1) - opentelemetry-api (~> 1.0) + opentelemetry-instrumentation-concurrent_ruby (0.23.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-excon (0.25.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-faraday (0.29.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-http (0.26.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-http_client (0.25.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-net_http (0.25.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-pg (0.31.1) opentelemetry-helpers-sql opentelemetry-helpers-sql-obfuscation - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rack (0.27.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-rails (0.37.0) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-action_mailer (~> 0.4.0) - opentelemetry-instrumentation-action_pack (~> 0.13.0) - opentelemetry-instrumentation-action_view (~> 0.9.0) - opentelemetry-instrumentation-active_job (~> 0.8.0) - opentelemetry-instrumentation-active_record (~> 0.9.0) - opentelemetry-instrumentation-active_storage (~> 0.1.0) - opentelemetry-instrumentation-active_support (~> 0.8.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) - opentelemetry-instrumentation-redis (0.26.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) - opentelemetry-instrumentation-sidekiq (0.26.1) - opentelemetry-api (~> 1.0) - opentelemetry-instrumentation-base (~> 0.23.0) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-rack (0.28.2) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-rails (0.38.0) + opentelemetry-instrumentation-action_mailer (~> 0.4) + opentelemetry-instrumentation-action_pack (~> 0.13) + opentelemetry-instrumentation-action_view (~> 0.9) + opentelemetry-instrumentation-active_job (~> 0.8) + opentelemetry-instrumentation-active_record (~> 0.9) + opentelemetry-instrumentation-active_storage (~> 0.1) + opentelemetry-instrumentation-active_support (~> 0.8) + opentelemetry-instrumentation-concurrent_ruby (~> 0.22) + opentelemetry-instrumentation-redis (0.27.1) + opentelemetry-instrumentation-base (~> 0.24) + opentelemetry-instrumentation-sidekiq (0.27.1) + opentelemetry-instrumentation-base (~> 0.24) opentelemetry-registry (0.4.0) opentelemetry-api (~> 1.1) opentelemetry-sdk (1.9.0) @@ -1034,19 +1009,19 @@ DEPENDENCIES omniauth_openid_connect (~> 0.8.0) opentelemetry-api (~> 1.7.0) opentelemetry-exporter-otlp (~> 0.30.0) - opentelemetry-instrumentation-active_job (~> 0.8.0) - opentelemetry-instrumentation-active_model_serializers (~> 0.22.0) - opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0) - opentelemetry-instrumentation-excon (~> 0.24.0) - opentelemetry-instrumentation-faraday (~> 0.28.0) - opentelemetry-instrumentation-http (~> 0.25.0) - opentelemetry-instrumentation-http_client (~> 0.24.0) - opentelemetry-instrumentation-net_http (~> 0.24.0) - opentelemetry-instrumentation-pg (~> 0.30.0) - opentelemetry-instrumentation-rack (~> 0.27.0) - opentelemetry-instrumentation-rails (~> 0.37.0) - opentelemetry-instrumentation-redis (~> 0.26.0) - opentelemetry-instrumentation-sidekiq (~> 0.26.0) + opentelemetry-instrumentation-active_job (~> 0.9.0) + opentelemetry-instrumentation-active_model_serializers (~> 0.23.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0) + opentelemetry-instrumentation-excon (~> 0.25.0) + opentelemetry-instrumentation-faraday (~> 0.29.0) + opentelemetry-instrumentation-http (~> 0.26.0) + opentelemetry-instrumentation-http_client (~> 0.25.0) + opentelemetry-instrumentation-net_http (~> 0.25.0) + opentelemetry-instrumentation-pg (~> 0.31.0) + opentelemetry-instrumentation-rack (~> 0.28.0) + opentelemetry-instrumentation-rails (~> 0.38.0) + opentelemetry-instrumentation-redis (~> 0.27.0) + opentelemetry-instrumentation-sidekiq (~> 0.27.0) opentelemetry-sdk (~> 1.4) ox (~> 2.14) parslet From 2b213e9b1b2eabb5bec0405596346b3bb50e5494 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:17:42 +0200 Subject: [PATCH 28/44] Update dependency ruby to v3.4.7 (#36387) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .ruby-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ruby-version b/.ruby-version index 1cf8253024..2aa5131992 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.6 +3.4.7 From 0c1ca6c969fcc2ba1c6ad17ff1a94a4ee8711f75 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 16:18:11 +0200 Subject: [PATCH 29/44] Emoji: Statuses (#36393) --- .../mastodon/components/content_warning.tsx | 43 +++++++++++++----- app/javascript/mastodon/components/poll.tsx | 6 ++- app/javascript/mastodon/components/status.jsx | 4 +- .../components/status/handled_link.tsx | 9 ++-- .../mastodon/components/status_banner.tsx | 7 +-- .../compose/components/edit_indicator.jsx | 4 +- .../compose/components/reply_indicator.jsx | 4 +- .../directory/components/account_card.tsx | 8 ++-- .../mastodon/features/emoji/normalize.ts | 6 +++ .../mastodon/features/emoji/types.ts | 3 +- .../components/account_authorize.jsx | 14 +++--- .../components/embedded_status.tsx | 28 +++++------- .../components/embedded_status_content.tsx | 45 +++++++++++++------ .../status/components/detailed_status.tsx | 18 +++----- 14 files changed, 121 insertions(+), 78 deletions(-) diff --git a/app/javascript/mastodon/components/content_warning.tsx b/app/javascript/mastodon/components/content_warning.tsx index 6bcae1d6f7..a407ec146e 100644 --- a/app/javascript/mastodon/components/content_warning.tsx +++ b/app/javascript/mastodon/components/content_warning.tsx @@ -1,15 +1,38 @@ +import type { List } from 'immutable'; + +import type { CustomEmoji } from '../models/custom_emoji'; +import type { Status } from '../models/status'; + +import { EmojiHTML } from './emoji/html'; import { StatusBanner, BannerVariant } from './status_banner'; export const ContentWarning: React.FC<{ - text: string; + status: Status; expanded?: boolean; onClick?: () => void; -}> = ({ text, expanded, onClick }) => ( - - - -); +}> = ({ status, expanded, onClick }) => { + const hasSpoiler = !!status.get('spoiler_text'); + if (!hasSpoiler) { + return null; + } + + const text = + status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml'); + if (typeof text !== 'string' || text.length === 0) { + return null; + } + + return ( + + } + /> + + ); +}; diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index 80444f6406..a9229e6ee4 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { animated, useSpring } from '@react-spring/web'; import escapeTextContentForBrowser from 'escape-html'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { openModal } from 'mastodon/actions/modal'; import { fetchPoll, vote } from 'mastodon/actions/polls'; @@ -305,10 +306,11 @@ const PollOption: React.FC = (props) => { )} - {!!voted && ( diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 196da7c99a..2a8c9bfb2d 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -118,7 +118,7 @@ class Status extends ImmutablePureComponent { unread: PropTypes.bool, showThread: PropTypes.bool, isQuotedPost: PropTypes.bool, - shouldHighlightOnMount: PropTypes.bool, + shouldHighlightOnMount: PropTypes.bool, getScrollPosition: PropTypes.func, updateScrollBottom: PropTypes.func, cacheMediaWidth: PropTypes.func, @@ -600,7 +600,7 @@ class Status extends ImmutablePureComponent { {matchedFilters && } - {(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && } + {(!matchedFilters || this.state.showDespiteFilter) && } {expanded && ( <> diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index d403038182..83262886e8 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -83,14 +83,15 @@ export const HandledLink: FC> = ({ export const useElementHandledLink = ({ hashtagAccountId, - mentionAccountId, + hrefToMentionAccountId, }: { hashtagAccountId?: string; - mentionAccountId?: string; + hrefToMentionAccountId?: (href: string) => string | undefined; } = {}) => { const onElement = useCallback( (element, { key, ...props }) => { if (element instanceof HTMLAnchorElement) { + const mentionId = hrefToMentionAccountId?.(element.href); return ( ); } return undefined; }, - [hashtagAccountId, mentionAccountId], + [hashtagAccountId, hrefToMentionAccountId], ); return { onElement }; }; diff --git a/app/javascript/mastodon/components/status_banner.tsx b/app/javascript/mastodon/components/status_banner.tsx index e11b2c9279..a1d200133f 100644 --- a/app/javascript/mastodon/components/status_banner.tsx +++ b/app/javascript/mastodon/components/status_banner.tsx @@ -3,6 +3,8 @@ import { useCallback, useRef, useId } from 'react'; import { FormattedMessage } from 'react-intl'; +import { AnimateEmojiProvider } from './emoji/context'; + export enum BannerVariant { Warning = 'warning', Filter = 'filter', @@ -34,8 +36,7 @@ export const StatusBanner: React.FC<{ return ( // Element clicks are passed on to button - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
)} -
+ ); }; diff --git a/app/javascript/mastodon/features/compose/components/edit_indicator.jsx b/app/javascript/mastodon/features/compose/components/edit_indicator.jsx index 106ff7bdaa..3fa37bb8c8 100644 --- a/app/javascript/mastodon/features/compose/components/edit_indicator.jsx +++ b/app/javascript/mastodon/features/compose/components/edit_indicator.jsx @@ -50,9 +50,7 @@ export const EditIndicator = () => { {(status.get('poll') || status.get('media_attachments').size > 0) && ( diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx index 35733ac23b..e746fe6a6d 100644 --- a/app/javascript/mastodon/features/compose/components/reply_indicator.jsx +++ b/app/javascript/mastodon/features/compose/components/reply_indicator.jsx @@ -35,9 +35,7 @@ export const ReplyIndicator = () => { {(status.get('poll') || status.get('media_attachments').size > 0) && ( diff --git a/app/javascript/mastodon/features/directory/components/account_card.tsx b/app/javascript/mastodon/features/directory/components/account_card.tsx index 6dc70532ab..562a72b4e8 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.tsx +++ b/app/javascript/mastodon/features/directory/components/account_card.tsx @@ -2,6 +2,7 @@ import { FormattedMessage } from 'react-intl'; import { Link } from 'react-router-dom'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { Avatar } from 'mastodon/components/avatar'; import { DisplayName } from 'mastodon/components/display_name'; import { FollowButton } from 'mastodon/components/follow_button'; @@ -39,9 +40,10 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { {account.get('note').length > 0 && ( -
)} diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 65667dfe6d..7c4252017b 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -154,6 +154,12 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { if (!extraEmojis) { return null; } + if (Array.isArray(extraEmojis)) { + return extraEmojis.reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); + } if (!isList(extraEmojis)) { return extraEmojis; } diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index 043b21361b..a98d931ea5 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -56,7 +56,8 @@ export type EmojiStateMap = LimitedCache; export type CustomEmojiMapArg = | ExtraCustomEmojiMap - | ImmutableList; + | ImmutableList + | CustomEmoji[]; export type ExtraCustomEmojiMap = Record< string, diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx b/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx index dd308c87cb..e865b606fe 100644 --- a/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx +++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.jsx @@ -10,9 +10,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { Avatar } from '../../../components/avatar'; -import { DisplayName } from '../../../components/display_name'; -import { IconButton } from '../../../components/icon_button'; +import { Avatar } from '@/mastodon/components/avatar'; +import { DisplayName } from '@/mastodon/components/display_name'; +import { IconButton } from '@/mastodon/components/icon_button'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; const messages = defineMessages({ authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, @@ -30,7 +31,6 @@ class AccountAuthorize extends ImmutablePureComponent { render () { const { intl, account, onAuthorize, onReject } = this.props; - const content = { __html: account.get('note_emojified') }; return (
@@ -40,7 +40,11 @@ class AccountAuthorize extends ImmutablePureComponent { -
+
diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx index 8e5e72b6aa..49bf364f05 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status.tsx @@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom'; import type { List as ImmutableList, RecordOf } from 'immutable'; +import type { ApiMentionJSON } from '@/mastodon/api_types/statuses'; import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; @@ -18,7 +19,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store'; import { EmbeddedStatusContent } from './embedded_status_content'; -export type Mention = RecordOf<{ url: string; acct: string }>; +export type Mention = RecordOf; export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ statusId, @@ -86,12 +87,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ } // Assign status attributes to variables with a forced type, as status is not yet properly typed - const contentHtml = status.get('contentHtml') as string; - const contentWarning = status.get('spoilerHtml') as string; + const hasContentWarning = !!status.get('spoiler_text'); const poll = status.get('poll'); - const language = status.get('language') as string; - const mentions = status.get('mentions') as ImmutableList; - const expanded = !status.get('hidden') || !contentWarning; + const expanded = !status.get('hidden') || !hasContentWarning; const mediaAttachmentsSize = ( status.get('media_attachments') as ImmutableList ).size; @@ -109,20 +107,16 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
- {contentWarning && ( - - )} + - {(!contentWarning || expanded) && ( + {(!hasContentWarning || expanded) && ( )} diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx index 855e160fac..91c3abde38 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; @@ -6,16 +6,22 @@ import type { List } from 'immutable'; import type { History } from 'history'; +import type { ApiMentionJSON } from '@/mastodon/api_types/statuses'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; +import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; +import type { Status } from '@/mastodon/models/status'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + import type { Mention } from './embedded_status'; const handleMentionClick = ( history: History, - mention: Mention, + mention: ApiMentionJSON, e: MouseEvent, ) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); - history.push(`/@${mention.get('acct')}`); + history.push(`/@${mention.acct}`); } }; @@ -31,16 +37,26 @@ const handleHashtagClick = ( }; export const EmbeddedStatusContent: React.FC<{ - content: string; - mentions: List; - language: string; + status: Status; className?: string; -}> = ({ content, mentions, language, className }) => { +}> = ({ status, className }) => { const history = useHistory(); + const mentions = useMemo( + () => (status.get('mentions') as List).toJS(), + [status], + ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: status.get('account') as string | undefined, + hrefToMentionAccountId(href) { + const mention = mentions.find((item) => item.url === href); + return mention?.id; + }, + }); + const handleContentRef = useCallback( (node: HTMLDivElement | null) => { - if (!node) { + if (!node || isModernEmojiEnabled()) { return; } @@ -53,7 +69,7 @@ export const EmbeddedStatusContent: React.FC<{ link.classList.add('status-link'); - const mention = mentions.find((item) => link.href === item.get('url')); + const mention = mentions.find((item) => link.href === item.url); if (mention) { link.addEventListener( @@ -61,8 +77,8 @@ export const EmbeddedStatusContent: React.FC<{ handleMentionClick.bind(null, history, mention), false, ); - link.setAttribute('title', `@${mention.get('acct')}`); - link.setAttribute('href', `/@${mention.get('acct')}`); + link.setAttribute('title', `@${mention.acct}`); + link.setAttribute('href', `/@${mention.acct}`); } else if ( link.textContent.startsWith('#') || link.previousSibling?.textContent?.endsWith('#') @@ -83,11 +99,12 @@ export const EmbeddedStatusContent: React.FC<{ ); return ( -
); }; diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx index b09e109afb..9b525b616c 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.tsx +++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx @@ -394,17 +394,13 @@ export const DetailedStatus: React.FC<{ /> )} - {status.get('spoiler_text').length > 0 && - (!matchedFilters || showDespiteFilter) && ( - - )} + {(!matchedFilters || showDespiteFilter) && ( + + )} {expanded && ( <> From 5c92312d4d3b96c076fd384d01e7ff9c0958e3dc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:19:27 +0200 Subject: [PATCH 30/44] Update dependency cross-env to v10.1.0 (#36297) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6dc3809d4b..20d256424a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6173,15 +6173,15 @@ __metadata: linkType: hard "cross-env@npm:^10.0.0": - version: 10.0.0 - resolution: "cross-env@npm:10.0.0" + version: 10.1.0 + resolution: "cross-env@npm:10.1.0" dependencies: "@epic-web/invariant": "npm:^1.0.0" cross-spawn: "npm:^7.0.6" bin: cross-env: dist/bin/cross-env.js cross-env-shell: dist/bin/cross-env-shell.js - checksum: 10c0/d16ffc3734106577d57b6253d81ab50294623bd59f96e161033eaf99c1c308ffbaba8463c23a6c0f72e841eff467cb7007a0a551f27554fcf2bbf6598cd828f9 + checksum: 10c0/834a862db456ba1fedf6c6da43436b123ae38f514fa286d6f0937c14fa83f13469f77f70f2812db041ae2d84f82bac627040b8686030aca27fbdf113dfa38b63 languageName: node linkType: hard From babb7b2b9d13d6c9041edfe3ab6fe9fba708709c Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 17:07:01 +0200 Subject: [PATCH 31/44] Emoji: Announcements (#36397) Co-authored-by: diondiondion --- .../mastodon/api_types/announcements.ts | 28 +++++ .../mastodon/features/emoji/normalize.ts | 16 +-- .../mastodon/features/emoji/types.ts | 3 +- .../components/announcements/announcement.tsx | 119 ++++++++++++++++++ .../components/announcements/index.tsx | 118 +++++++++++++++++ .../components/announcements/reactions.tsx | 108 ++++++++++++++++ .../mastodon/features/home_timeline/index.jsx | 4 +- 7 files changed, 385 insertions(+), 11 deletions(-) create mode 100644 app/javascript/mastodon/api_types/announcements.ts create mode 100644 app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx create mode 100644 app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx create mode 100644 app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx diff --git a/app/javascript/mastodon/api_types/announcements.ts b/app/javascript/mastodon/api_types/announcements.ts new file mode 100644 index 0000000000..03e8922d8f --- /dev/null +++ b/app/javascript/mastodon/api_types/announcements.ts @@ -0,0 +1,28 @@ +// See app/serializers/rest/announcement_serializer.rb + +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses'; + +export interface ApiAnnouncementJSON { + id: string; + content: string; + starts_at: null | string; + ends_at: null | string; + all_day: boolean; + published_at: string; + updated_at: null | string; + read: boolean; + mentions: ApiMentionJSON[]; + statuses: ApiStatusJSON[]; + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + reactions: ApiAnnouncementReactionJSON[]; +} + +export interface ApiAnnouncementReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; +} diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index 7c4252017b..a09505e97f 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -160,15 +160,15 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { {}, ); } - if (!isList(extraEmojis)) { - return extraEmojis; + if (isList(extraEmojis)) { + return extraEmojis + .toJS() + .reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); } - return extraEmojis - .toJSON() - .reduce( - (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), - {}, - ); + return extraEmojis; } function hexStringToNumbers(hexString: string): number[] { diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts index a98d931ea5..b55cefb0a5 100644 --- a/app/javascript/mastodon/features/emoji/types.ts +++ b/app/javascript/mastodon/features/emoji/types.ts @@ -57,7 +57,8 @@ export type EmojiStateMap = LimitedCache; export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList - | CustomEmoji[]; + | CustomEmoji[] + | ApiCustomEmojiJSON[]; export type ExtraCustomEmojiMap = Record< string, diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx new file mode 100644 index 0000000000..8513e6169b --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import type { FC } from 'react'; + +import { FormattedDate, FormattedMessage } from 'react-intl'; + +import type { ApiAnnouncementJSON } from '@/mastodon/api_types/announcements'; +import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; +import { EmojiHTML } from '@/mastodon/components/emoji/html'; + +import { ReactionsBar } from './reactions'; + +export interface IAnnouncement extends ApiAnnouncementJSON { + contentHtml: string; +} + +interface AnnouncementProps { + announcement: IAnnouncement; + selected: boolean; +} + +export const Announcement: FC = ({ + announcement, + selected, +}) => { + const [unread, setUnread] = useState(!announcement.read); + useEffect(() => { + // Only update `unread` marker once the announcement is out of view + if (!selected && unread !== !announcement.read) { + setUnread(!announcement.read); + } + }, [announcement.read, selected, unread]); + + return ( + + + + + {' · '} + + + + + + + + + {unread && } + + ); +}; + +const Timestamp: FC> = ({ + announcement, +}) => { + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipTime = announcement.all_day; + + if (hasTimeRange) { + const skipYear = + startsAt.getFullYear() === endsAt.getFullYear() && + endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = + startsAt.getDate() === endsAt.getDate() && + startsAt.getMonth() === endsAt.getMonth() && + startsAt.getFullYear() === endsAt.getFullYear(); + return ( + <> + {' '} + -{' '} + + + ); + } + const publishedAt = new Date(announcement.published_at); + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx new file mode 100644 index 0000000000..8c7c704849 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx @@ -0,0 +1,118 @@ +import { useCallback, useState } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import type { Map, List } from 'immutable'; + +import ReactSwipeableViews from 'react-swipeable-views'; + +import elephantUIPlane from '@/images/elephant_ui_plane.svg'; +import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; +import { IconButton } from '@/mastodon/components/icon_button'; +import LegacyAnnouncements from '@/mastodon/features/getting_started/containers/announcements_container'; +import { mascot, reduceMotion } from '@/mastodon/initial_state'; +import { createAppSelector, useAppSelector } from '@/mastodon/store'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; + +import type { IAnnouncement } from './announcement'; +import { Announcement } from './announcement'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +const announcementSelector = createAppSelector( + [(state) => state.announcements as Map>>], + (announcements) => + (announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [], +); + +export const ModernAnnouncements: FC = () => { + const intl = useIntl(); + + const announcements = useAppSelector(announcementSelector); + const emojis = useAppSelector((state) => state.custom_emojis); + + const [index, setIndex] = useState(0); + const handleChangeIndex = useCallback( + (idx: number) => { + setIndex(idx % announcements.length); + }, + [announcements.length], + ); + const handleNextIndex = useCallback(() => { + setIndex((prevIndex) => (prevIndex + 1) % announcements.length); + }, [announcements.length]); + const handlePrevIndex = useCallback(() => { + setIndex((prevIndex) => + prevIndex === 0 ? announcements.length - 1 : prevIndex - 1, + ); + }, [announcements.length]); + + if (announcements.length === 0) { + return null; + } + + return ( +
+ + +
+ + + {announcements + .map((announcement, idx) => ( + + )) + .reverse()} + + + + {announcements.length > 1 && ( +
+ + + {index + 1} / {announcements.length} + + +
+ )} +
+
+ ); +}; + +export const Announcements = isModernEmojiEnabled() + ? ModernAnnouncements + : LegacyAnnouncements; diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx new file mode 100644 index 0000000000..481e87f190 --- /dev/null +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/reactions.tsx @@ -0,0 +1,108 @@ +import { useCallback, useMemo } from 'react'; +import type { FC, HTMLAttributes } from 'react'; + +import classNames from 'classnames'; + +import type { AnimatedProps } from '@react-spring/web'; +import { animated, useTransition } from '@react-spring/web'; + +import { addReaction, removeReaction } from '@/mastodon/actions/announcements'; +import type { ApiAnnouncementReactionJSON } from '@/mastodon/api_types/announcements'; +import { AnimatedNumber } from '@/mastodon/components/animated_number'; +import { Emoji } from '@/mastodon/components/emoji'; +import { Icon } from '@/mastodon/components/icon'; +import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container'; +import { isUnicodeEmoji } from '@/mastodon/features/emoji/utils'; +import { useAppDispatch } from '@/mastodon/store'; +import AddIcon from '@/material-icons/400-24px/add.svg?react'; + +export const ReactionsBar: FC<{ + reactions: ApiAnnouncementReactionJSON[]; + id: string; +}> = ({ reactions, id }) => { + const visibleReactions = useMemo( + () => reactions.filter((x) => x.count > 0), + [reactions], + ); + + const dispatch = useAppDispatch(); + const handleEmojiPick = useCallback( + (emoji: { native: string }) => { + dispatch(addReaction(id, emoji.native.replaceAll(/:/g, ''))); + }, + [dispatch, id], + ); + + const transitions = useTransition(visibleReactions, { + from: { + scale: 0, + }, + enter: { + scale: 1, + }, + leave: { + scale: 0, + }, + keys: visibleReactions.map((x) => x.name), + }); + + return ( +
+ {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + id={id} + /> + ))} + + {visibleReactions.length < 8 && ( + } + /> + )} +
+ ); +}; + +const Reaction: FC<{ + reaction: ApiAnnouncementReactionJSON; + id: string; + style: AnimatedProps>['style']; +}> = ({ id, reaction, style }) => { + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + if (reaction.me) { + dispatch(removeReaction(id, reaction.name)); + } else { + dispatch(addReaction(id, reaction.name)); + } + }, [dispatch, id, reaction.me, reaction.name]); + + const code = isUnicodeEmoji(reaction.name) + ? reaction.name + : `:${reaction.name}:`; + + return ( + + + + + + + + + ); +}; diff --git a/app/javascript/mastodon/features/home_timeline/index.jsx b/app/javascript/mastodon/features/home_timeline/index.jsx index 39a8355b89..8c5555fd49 100644 --- a/app/javascript/mastodon/features/home_timeline/index.jsx +++ b/app/javascript/mastodon/features/home_timeline/index.jsx @@ -14,7 +14,6 @@ import { SymbolLogo } from 'mastodon/components/logo'; import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements'; import { IconWithBadge } from 'mastodon/components/icon_with_badge'; import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator'; -import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { criticalUpdatesPending } from 'mastodon/initial_state'; import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint'; @@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; import { CriticalUpdateBanner } from './components/critical_update_banner'; +import { Announcements } from './components/announcements'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -162,7 +162,7 @@ class HomeTimeline extends PureComponent { pinned={pinned} multiColumn={multiColumn} extraButton={announcementsButton} - appendContent={hasAnnouncements && showAnnouncements && } + appendContent={hasAnnouncements && showAnnouncements && } > From b8444d9bb7885d75112a3cae74b6a5c711c7d547 Mon Sep 17 00:00:00 2001 From: Renaud Chaput Date: Wed, 8 Oct 2025 17:51:53 +0200 Subject: [PATCH 32/44] Do not automatically run Prettier on the streaming server code. (#36400) --- streaming/lint-staged.config.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/streaming/lint-staged.config.mjs b/streaming/lint-staged.config.mjs index 430a999b9a..5f9230acbd 100644 --- a/streaming/lint-staged.config.mjs +++ b/streaming/lint-staged.config.mjs @@ -1,5 +1,4 @@ const config = { - '*': 'prettier --ignore-unknown --write', '*.{js,ts}': 'eslint --fix', '**/*.ts': () => 'tsc -p tsconfig.json --noEmit', }; From 0281768cfdda5a92b1eee2706b13faa22f642c05 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 11:31:10 +0200 Subject: [PATCH 33/44] [Glitch] Emoji: Link Replacement Port ffac4cb05f5c5a8f91074c846b74fc1dd0c5faf3 to glitch-soc Signed-off-by: Claire --- .../glitch/components/account_bio.tsx | 40 ++++-- .../flavours/glitch/components/emoji/html.tsx | 99 +++++++++------ .../status/handled_link.stories.tsx | 65 ++++++++++ .../glitch/components/status/handled_link.tsx | 79 ++++++++++++ .../glitch/components/status_content.jsx | 49 ++++++-- app/javascript/flavours/glitch/utils/html.ts | 116 ++++++++++-------- 6 files changed, 335 insertions(+), 113 deletions(-) create mode 100644 app/javascript/flavours/glitch/components/status/handled_link.stories.tsx create mode 100644 app/javascript/flavours/glitch/components/status/handled_link.tsx diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx index 97717f47b8..8c221239ef 100644 --- a/app/javascript/flavours/glitch/components/account_bio.tsx +++ b/app/javascript/flavours/glitch/components/account_bio.tsx @@ -6,9 +6,10 @@ import { useLinks } from 'flavours/glitch/hooks/useLinks'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; +import type { OnElementHandler } from '../utils/html'; -import { AnimateEmojiProvider } from './emoji/context'; import { EmojiHTML } from './emoji/html'; +import { HandledLink } from './status/handled_link'; interface AccountBioProps { className: string; @@ -24,13 +25,37 @@ export const AccountBio: React.FC = ({ const handleClick = useLinks(showDropdown); const handleNodeChange = useCallback( (node: HTMLDivElement | null) => { - if (!showDropdown || !node || node.childNodes.length === 0) { + if ( + !showDropdown || + !node || + node.childNodes.length === 0 || + isModernEmojiEnabled() + ) { return; } addDropdownToHashtags(node, accountId); }, [showDropdown, accountId], ); + + const handleLink = useCallback( + (element, { key, ...props }) => { + if (element instanceof HTMLAnchorElement) { + return ( + + ); + } + return undefined; + }, + [accountId], + ); + const note = useAppSelector((state) => { const account = state.accounts.get(accountId); if (!account) { @@ -48,13 +73,14 @@ export const AccountBio: React.FC = ({ } return ( - - - + onElement={handleLink} + /> ); }; diff --git a/app/javascript/flavours/glitch/components/emoji/html.tsx b/app/javascript/flavours/glitch/components/emoji/html.tsx index 569b4730a5..dea3894265 100644 --- a/app/javascript/flavours/glitch/components/emoji/html.tsx +++ b/app/javascript/flavours/glitch/components/emoji/html.tsx @@ -1,60 +1,79 @@ 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 type { OnElementHandler } from '@/flavours/glitch/utils/html'; import { htmlStringToComponents } from '@/flavours/glitch/utils/html'; +import { polymorphicForwardRef } from '@/types/polymorphic'; import { AnimateEmojiProvider, CustomEmojiProvider } from './context'; import { textToEmojis } from './index'; -type EmojiHTMLProps = Omit< - ComponentPropsWithoutRef, - 'dangerouslySetInnerHTML' | 'className' -> & { +interface EmojiHTMLProps { htmlString: string; extraEmojis?: CustomEmojiMapArg; - as?: Element; className?: string; -}; + onElement?: OnElementHandler; +} -export const ModernEmojiHTML = ({ - extraEmojis, - htmlString, - as: asProp = 'div', // Rename for syntax highlighting - shallow, - className = '', - ...props -}: EmojiHTMLProps) => { - const contents = useMemo( - () => htmlStringToComponents(htmlString, { onText: textToEmojis }), - [htmlString], - ); +export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + ( + { + extraEmojis, + htmlString, + as: asProp = 'div', // Rename for syntax highlighting + className = '', + onElement, + ...props + }, + ref, + ) => { + const contents = useMemo( + () => + htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }), + [htmlString, onElement], + ); - return ( - - - {contents} - - - ); -}; + return ( + + + {contents} + + + ); + }, +); +ModernEmojiHTML.displayName = 'ModernEmojiHTML'; -export const LegacyEmojiHTML = ( - props: EmojiHTMLProps, -) => { - const { as: asElement, htmlString, extraEmojis, className, ...rest } = props; - const Wrapper = asElement ?? 'div'; - return ( - - ); -}; +export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( + (props, ref) => { + const { + as: asElement, + htmlString, + extraEmojis, + className, + onElement, + ...rest + } = props; + const Wrapper = asElement ?? 'div'; + return ( + + ); + }, +); +LegacyEmojiHTML.displayName = 'LegacyEmojiHTML'; export const EmojiHTML = isModernEmojiEnabled() ? ModernEmojiHTML diff --git a/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx b/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx new file mode 100644 index 0000000000..abc2aa6cef --- /dev/null +++ b/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller'; +import { accountFactoryState } from '@/testing/factories'; + +import { HoverCardController } from '../hover_card_controller'; + +import type { HandledLinkProps } from './handled_link'; +import { HandledLink } from './handled_link'; + +const meta = { + title: 'Components/Status/HandledLink', + render(args) { + return ( + <> + + + + + ); + }, + args: { + href: 'https://example.com/path/subpath?query=1#hash', + text: 'https://example.com', + }, + parameters: { + state: { + accounts: { + '1': accountFactoryState(), + }, + }, + }, +} satisfies Meta>; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Hashtag: Story = { + args: { + text: '#example', + }, +}; + +export const Mention: Story = { + args: { + text: '@user', + }, +}; + +export const InternalLink: Story = { + args: { + href: '/about', + text: 'About', + }, +}; + +export const InvalidURL: Story = { + args: { + href: 'ht!tp://invalid-url', + text: 'ht!tp://invalid-url -- invalid!', + }, +}; diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx new file mode 100644 index 0000000000..ee41321283 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -0,0 +1,79 @@ +import type { ComponentProps, FC } from 'react'; + +import { Link } from 'react-router-dom'; + +export interface HandledLinkProps { + href: string; + text: string; + hashtagAccountId?: string; + mentionAccountId?: string; +} + +export const HandledLink: FC> = ({ + href, + text, + hashtagAccountId, + mentionAccountId, + ...props +}) => { + // Handle hashtags + if (text.startsWith('#')) { + const hashtag = text.slice(1).trim(); + return ( + + #{hashtag} + + ); + } else if (text.startsWith('@')) { + // Handle mentions + const mention = text.slice(1).trim(); + return ( + + @{mention} + + ); + } + + // Non-absolute paths treated as internal links. + if (href.startsWith('/')) { + return ( + + {text} + + ); + } + + try { + const url = new URL(href); + const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. + return ( + + {url.protocol + '//'} + {`${url.hostname}/${first ?? ''}`} + {'/' + rest.join('/')} + + ); + } catch { + return text; + } +}; diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 2de11f0003..8b0ec56c82 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -19,6 +19,7 @@ import { decode as decodeIDNA } from 'flavours/glitch/utils/idna'; import { isModernEmojiEnabled } from '../utils/environment'; import { EmojiHTML } from './emoji/html'; +import { HandledLink } from './status/handled_link'; const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top) @@ -163,6 +164,23 @@ class StatusContent extends PureComponent { } const { status, onCollapsedToggle } = this.props; + if (status.get('collapsed', null) === null && onCollapsedToggle) { + const { collapsible, onClick } = this.props; + + const collapsed = + collapsible + && onClick + && node.clientHeight > MAX_HEIGHT + && status.get('spoiler_text').length === 0; + + onCollapsedToggle(collapsed); + } + + // Exit if modern emoji is enabled, as it handles links using the HandledLink component. + if (isModernEmojiEnabled()) { + return; + } + const links = node.querySelectorAll('a'); let link, mention; @@ -225,18 +243,6 @@ class StatusContent extends PureComponent { } } } - - if (status.get('collapsed', null) === null && onCollapsedToggle) { - const { collapsible, onClick } = this.props; - - const collapsed = - collapsible - && onClick - && node.clientHeight > MAX_HEIGHT - && status.get('spoiler_text').length === 0; - - onCollapsedToggle(collapsed); - } } componentDidMount () { @@ -298,6 +304,23 @@ class StatusContent extends PureComponent { this.node = c; }; + handleElement = (element, {key, ...props}) => { + if (element instanceof HTMLAnchorElement) { + const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); + return ( + + ); + } + return undefined; + } + render () { const { status, intl, statusContent } = this.props; @@ -342,6 +365,7 @@ class StatusContent extends PureComponent { lang={language} htmlString={content} extraEmojis={status.get('emojis')} + onElement={this.handleElement.bind(this)} /> {poll} @@ -359,6 +383,7 @@ class StatusContent extends PureComponent { lang={language} htmlString={content} extraEmojis={status.get('emojis')} + onElement={this.handleElement.bind(this)} /> {poll} diff --git a/app/javascript/flavours/glitch/utils/html.ts b/app/javascript/flavours/glitch/utils/html.ts index 52b0fb7b7e..bbda1b7be3 100644 --- a/app/javascript/flavours/glitch/utils/html.ts +++ b/app/javascript/flavours/glitch/utils/html.ts @@ -32,14 +32,21 @@ interface QueueItem { depth: number; } -export interface HTMLToStringOptions> { +export type OnElementHandler< + Arg extends Record = Record, +> = ( + element: HTMLElement, + props: Record, + children: React.ReactNode[], + extra: Arg, +) => React.ReactNode; + +export interface HTMLToStringOptions< + Arg extends Record = Record, +> { maxDepth?: number; onText?: (text: string, extra: Arg) => React.ReactNode; - onElement?: ( - element: HTMLElement, - children: React.ReactNode[], - extra: Arg, - ) => React.ReactNode; + onElement?: OnElementHandler; onAttribute?: ( name: string, value: string, @@ -125,9 +132,57 @@ export function htmlStringToComponents>( const children: React.ReactNode[] = []; let element: React.ReactNode = undefined; + // Generate props from attributes. + const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. + const props: Record = { key }; + for (const attr of node.attributes) { + let name = attr.name.toLowerCase(); + + // Custom attribute handler. + if (onAttribute) { + const result = onAttribute( + name, + attr.value, + node.tagName.toLowerCase(), + extraArgs, + ); + if (result) { + const [cbName, value] = result; + props[cbName] = value; + } + } else { + // Check global attributes first, then tag-specific ones. + const globalAttr = globalAttributes[name]; + const tagAttr = tagInfo.attributes?.[name]; + + // Exit if neither global nor tag-specific attribute is allowed. + if (!globalAttr && !tagAttr) { + continue; + } + + // Rename if needed. + if (typeof tagAttr === 'string') { + name = tagAttr; + } else if (typeof globalAttr === 'string') { + name = globalAttr; + } + + let value: string | boolean | number = attr.value; + + // Handle boolean attributes. + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } + + props[name] = value; + } + } + // If onElement is provided, use it to create the element. if (onElement) { - const component = onElement(node, children, extraArgs); + const component = onElement(node, props, children, extraArgs); // Check for undefined to allow returning null. if (component !== undefined) { @@ -137,53 +192,6 @@ export function htmlStringToComponents>( // If the element wasn't created, use the default conversion. if (element === undefined) { - const props: Record = {}; - props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it. - for (const attr of node.attributes) { - let name = attr.name.toLowerCase(); - - // Custom attribute handler. - if (onAttribute) { - const result = onAttribute( - name, - attr.value, - node.tagName.toLowerCase(), - extraArgs, - ); - if (result) { - const [cbName, value] = result; - props[cbName] = value; - } - } else { - // Check global attributes first, then tag-specific ones. - const globalAttr = globalAttributes[name]; - const tagAttr = tagInfo.attributes?.[name]; - - // Exit if neither global nor tag-specific attribute is allowed. - if (!globalAttr && !tagAttr) { - continue; - } - - // Rename if needed. - if (typeof tagAttr === 'string') { - name = tagAttr; - } else if (typeof globalAttr === 'string') { - name = globalAttr; - } - - let value: string | boolean | number = attr.value; - - // Handle boolean attributes. - if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; - } - - props[name] = value; - } - } - element = React.createElement( tagName, props, From d5ce785267a544c076fa5daed53ee027b4c741ab Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 15:34:51 +0200 Subject: [PATCH 34/44] [Glitch] Allow modern_emojis to be enabled purely server-side Port 68a36d5a57269e34c76305b49628a36a87c21b74 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/utils/environment.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/javascript/flavours/glitch/utils/environment.ts b/app/javascript/flavours/glitch/utils/environment.ts index c666e2c94d..c2b6b1cf86 100644 --- a/app/javascript/flavours/glitch/utils/environment.ts +++ b/app/javascript/flavours/glitch/utils/environment.ts @@ -20,10 +20,7 @@ export function isFeatureEnabled(feature: Features) { export function isModernEmojiEnabled() { try { - return ( - isFeatureEnabled('modern_emojis') && - localStorage.getItem('experiments')?.split(',').includes('modern_emojis') - ); + return isFeatureEnabled('modern_emojis'); } catch { return false; } From d34b4f3fd00657a329fd1f1fc82268b12082763b Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 6 Oct 2025 15:43:20 +0200 Subject: [PATCH 35/44] [Glitch] Add feature to automatically attach quote on eligible link past in Web UI composer Port cda07686dfbbc0b3f8dfbb38a54efa3d126f2c44 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/compose_typed.ts | 37 +++++++++++++++++++ .../components/autosuggest_textarea.jsx | 5 +-- .../containers/compose_form_container.js | 20 +++++++++- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts index f0219f7da7..ec929df848 100644 --- a/app/javascript/flavours/glitch/actions/compose_typed.ts +++ b/app/javascript/flavours/glitch/actions/compose_typed.ts @@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { List as ImmutableList, Map as ImmutableMap } from 'immutable'; import { apiUpdateMedia } from 'flavours/glitch/api/compose'; +import { apiGetSearch } from 'flavours/glitch/api/search'; import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments'; import type { MediaAttachment } from 'flavours/glitch/models/media_attachment'; import { @@ -16,6 +17,7 @@ import type { Status } from '../models/status'; import { showAlert } from './alerts'; import { focusCompose } from './compose'; +import { importFetchedStatuses } from './importer'; import { openModal } from './modal'; const messages = defineMessages({ @@ -165,6 +167,41 @@ export const quoteComposeById = createAppThunk( }, ); +export const pasteLinkCompose = createDataLoadingThunk( + 'compose/pasteLink', + async ({ url }: { url: string }) => { + return await apiGetSearch({ + q: url, + type: 'statuses', + resolve: true, + limit: 2, + }); + }, + (data, { dispatch, getState }) => { + const composeState = getState().compose; + + if ( + composeState.get('quoted_status_id') || + composeState.get('is_submitting') || + composeState.get('poll') || + composeState.get('is_uploading') + ) + return; + + dispatch(importFetchedStatuses(data.statuses)); + + if ( + data.statuses.length === 1 && + data.statuses[0] && + ['automatic', 'manual'].includes( + data.statuses[0].quote_approval?.current_user ?? 'denied', + ) + ) { + dispatch(quoteComposeById(data.statuses[0].id)); + } + }, +); + export const quoteComposeCancel = createAction('compose/quoteComposeCancel'); export const setComposeQuotePolicy = createAction( diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx index de5accc4b2..68cf9e17fc 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx @@ -150,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({ }, [suggestions, onSuggestionSelected, textareaRef]); const handlePaste = useCallback((e) => { - if (e.clipboardData && e.clipboardData.files.length === 1) { - onPaste(e.clipboardData.files); - e.preventDefault(); - } + onPaste(e); }, [onPaste]); // Show the suggestions again whenever they change and the textarea is focused diff --git a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js index 239f2f8a3d..fd7186d71a 100644 --- a/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js +++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js @@ -10,11 +10,14 @@ import { insertEmojiCompose, uploadCompose, } from 'flavours/glitch/actions/compose'; +import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed'; import { openModal } from 'flavours/glitch/actions/modal'; import { privacyPreference } from 'flavours/glitch/utils/privacy_preference'; import ComposeForm from '../components/compose_form'; +const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i; + const sideArmPrivacy = state => { const inReplyTo = state.getIn(['compose', 'in_reply_to']); const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null; @@ -93,8 +96,21 @@ const mapDispatchToProps = (dispatch, props) => ({ dispatch(changeComposeSpoilerText(checked)); }, - onPaste (files) { - dispatch(uploadCompose(files)); + onPaste (e) { + if (e.clipboardData && e.clipboardData.files.length === 1) { + dispatch(uploadCompose(e.clipboardData.files)); + e.preventDefault(); + } else if (e.clipboardData && e.clipboardData.files.length === 0) { + const data = e.clipboardData.getData('text/plain'); + if (!data.match(urlLikeRegex)) return; + + try { + const url = new URL(data); + dispatch(pasteLinkCompose({ url })); + } catch { + return; + } + } }, onPickEmoji (position, data, needsSpace) { From da99ec0eea22209b9d06185f65c3d94fd1448155 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 6 Oct 2025 16:13:24 +0200 Subject: [PATCH 36/44] [Glitch] Fetch all replies: Only display "More replies found" prompt when there really are new replies Port 474fbb2770a4bff97e25d07897a21acfda373262 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/actions/statuses_typed.ts | 14 +- .../status/components/refresh_controller.tsx | 120 +++++++++--------- .../flavours/glitch/reducers/contexts.ts | 109 ++++++++++++---- 3 files changed, 155 insertions(+), 88 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/statuses_typed.ts b/app/javascript/flavours/glitch/actions/statuses_typed.ts index 4472cbad25..039fbf3ac5 100644 --- a/app/javascript/flavours/glitch/actions/statuses_typed.ts +++ b/app/javascript/flavours/glitch/actions/statuses_typed.ts @@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer'; export const fetchContext = createDataLoadingThunk( 'status/context', - ({ statusId }: { statusId: string }) => apiGetContext(statusId), - ({ context, refresh }, { dispatch }) => { + ({ statusId }: { statusId: string; prefetchOnly?: boolean }) => + apiGetContext(statusId), + ({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => { const statuses = context.ancestors.concat(context.descendants); dispatch(importFetchedStatuses(statuses)); @@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk( return { context, refresh, + prefetchOnly, }; }, ); @@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>( 'status/context/complete', ); +export const showPendingReplies = createAction<{ statusId: string }>( + 'status/context/showPendingReplies', +); + +export const clearPendingReplies = createAction<{ statusId: string }>( + 'status/context/clearPendingReplies', +); + export const setStatusQuotePolicy = createDataLoadingThunk( 'status/setQuotePolicy', ({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => { diff --git a/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx b/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx index 1bf5b5b3ef..d97a211d95 100644 --- a/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx +++ b/app/javascript/flavours/glitch/features/status/components/refresh_controller.tsx @@ -5,6 +5,8 @@ import { useIntl, defineMessages } from 'react-intl'; import { fetchContext, completeContextRefresh, + showPendingReplies, + clearPendingReplies, } from 'flavours/glitch/actions/statuses'; import type { AsyncRefreshHeader } from 'flavours/glitch/api'; import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes'; @@ -34,10 +36,6 @@ const messages = defineMessages({ id: 'status.context.loading', defaultMessage: 'Loading', }, - loadingMore: { - id: 'status.context.loading_more', - defaultMessage: 'Loading more replies', - }, success: { id: 'status.context.loading_success', defaultMessage: 'All replies loaded', @@ -52,36 +50,33 @@ const messages = defineMessages({ }, }); -type LoadingState = - | 'idle' - | 'more-available' - | 'loading-initial' - | 'loading-more' - | 'success' - | 'error'; +type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error'; export const RefreshController: React.FC<{ statusId: string; }> = ({ statusId }) => { - const refresh = useAppSelector( - (state) => state.contexts.refreshing[statusId], - ); - const currentReplyCount = useAppSelector( - (state) => state.contexts.replies[statusId]?.length ?? 0, - ); - const autoRefresh = !currentReplyCount; const dispatch = useAppDispatch(); const intl = useIntl(); - const [loadingState, setLoadingState] = useState( - refresh && autoRefresh ? 'loading-initial' : 'idle', + const refreshHeader = useAppSelector( + (state) => state.contexts.refreshing[statusId], ); + const hasPendingReplies = useAppSelector( + (state) => !!state.contexts.pendingReplies[statusId]?.length, + ); + const [partialLoadingState, setLoadingState] = useState( + refreshHeader ? 'loading' : 'idle', + ); + const loadingState = hasPendingReplies + ? 'more-available' + : partialLoadingState; const [wasDismissed, setWasDismissed] = useState(false); const dismissPrompt = useCallback(() => { setWasDismissed(true); setLoadingState('idle'); - }, []); + dispatch(clearPendingReplies({ statusId })); + }, [dispatch, statusId]); useEffect(() => { let timeoutId: ReturnType; @@ -89,36 +84,51 @@ export const RefreshController: React.FC<{ const scheduleRefresh = (refresh: AsyncRefreshHeader) => { timeoutId = setTimeout(() => { void apiGetAsyncRefresh(refresh.id).then((result) => { - if (result.async_refresh.status === 'finished') { - dispatch(completeContextRefresh({ statusId })); - - if (result.async_refresh.result_count > 0) { - if (autoRefresh) { - void dispatch(fetchContext({ statusId })).then(() => { - setLoadingState('idle'); - }); - } else { - setLoadingState('more-available'); - } - } else { - setLoadingState('idle'); - } - } else { + // If the refresh status is not finished, + // schedule another refresh and exit + if (result.async_refresh.status !== 'finished') { scheduleRefresh(refresh); + return; } + + // Refresh status is finished. The action below will clear `refreshHeader` + dispatch(completeContextRefresh({ statusId })); + + // Exit if there's nothing to fetch + if (result.async_refresh.result_count === 0) { + setLoadingState('idle'); + return; + } + + // A positive result count means there _might_ be new replies, + // so we fetch the context in the background to check if there + // are any new replies. + // If so, they will populate `contexts.pendingReplies[statusId]` + void dispatch(fetchContext({ statusId, prefetchOnly: true })) + .then(() => { + // Reset loading state to `idle` – but if the fetch + // has resulted in new pending replies, the `hasPendingReplies` + // flag will switch the loading state to 'more-available' + setLoadingState('idle'); + }) + .catch(() => { + // Show an error if the fetch failed + setLoadingState('error'); + }); }); }, refresh.retry * 1000); }; - if (refresh && !wasDismissed) { - scheduleRefresh(refresh); - setLoadingState('loading-initial'); + // Initialise a refresh + if (refreshHeader && !wasDismissed) { + scheduleRefresh(refreshHeader); + setLoadingState('loading'); } return () => { clearTimeout(timeoutId); }; - }, [dispatch, statusId, refresh, autoRefresh, wasDismissed]); + }, [dispatch, statusId, refreshHeader, wasDismissed]); useEffect(() => { // Hide success message after a short delay @@ -134,20 +144,19 @@ export const RefreshController: React.FC<{ return () => ''; }, [loadingState]); - const handleClick = useCallback(() => { - setLoadingState('loading-more'); - - dispatch(fetchContext({ statusId })) - .then(() => { - setLoadingState('success'); - return ''; - }) - .catch(() => { - setLoadingState('error'); - }); + useEffect(() => { + // Clear pending replies on unmount + return () => { + dispatch(clearPendingReplies({ statusId })); + }; }, [dispatch, statusId]); - if (loadingState === 'loading-initial') { + const handleClick = useCallback(() => { + dispatch(showPendingReplies({ statusId })); + setLoadingState('success'); + }, [dispatch, statusId]); + + if (loadingState === 'loading') { return (
- ; replies: Record; + pendingReplies: Record< + string, + Pick[] + >; refreshing: Record; } const initialState: State = { inReplyTos: {}, replies: {}, + pendingReplies: {}, refreshing: {}, }; +const addReply = ( + state: Draft, + { id, in_reply_to_id }: Pick, +) => { + if (!in_reply_to_id) { + return; + } + + if (!state.inReplyTos[id]) { + const siblings = (state.replies[in_reply_to_id] ??= []); + const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0); + siblings.splice(index + 1, 0, id); + state.inReplyTos[id] = in_reply_to_id; + } +}; + const normalizeContext = ( state: Draft, id: string, { ancestors, descendants }: ApiContextJSON, ): void => { - const addReply = ({ - id, - in_reply_to_id, - }: { - id: string; - in_reply_to_id?: string; - }) => { - if (!in_reply_to_id) { - return; - } - - if (!state.inReplyTos[id]) { - const siblings = (state.replies[in_reply_to_id] ??= []); - const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0); - siblings.splice(index + 1, 0, id); - state.inReplyTos[id] = in_reply_to_id; - } - }; + ancestors.forEach((item) => { + addReply(state, item); + }); // We know in_reply_to_id of statuses but `id` itself. // So we assume that the status of the id replies to last ancestors. - - ancestors.forEach(addReply); - if (ancestors[0]) { - addReply({ + addReply(state, { id, in_reply_to_id: ancestors[ancestors.length - 1]?.id, }); } - descendants.forEach(addReply); + descendants.forEach((item) => { + addReply(state, item); + }); +}; + +const applyPrefetchedReplies = (state: Draft, statusId: string) => { + const pendingReplies = state.pendingReplies[statusId]; + if (pendingReplies?.length) { + pendingReplies.forEach((item) => { + addReply(state, item); + }); + delete state.pendingReplies[statusId]; + } +}; + +const storePrefetchedReplies = ( + state: Draft, + statusId: string, + { descendants }: ApiContextJSON, +): void => { + descendants.forEach(({ id, in_reply_to_id }) => { + if (!in_reply_to_id) { + return; + } + const isNewReply = !state.replies[in_reply_to_id]?.includes(id); + if (isNewReply) { + const pendingReplies = (state.pendingReplies[statusId] ??= []); + pendingReplies.push({ id, in_reply_to_id }); + } + }); }; const deleteFromContexts = (state: Draft, ids: string[]): void => { @@ -129,12 +166,30 @@ const updateContext = (state: Draft, status: ApiStatusJSON): void => { export const contextsReducer = createReducer(initialState, (builder) => { builder .addCase(fetchContext.fulfilled, (state, action) => { - normalizeContext(state, action.meta.arg.statusId, action.payload.context); + if (action.payload.prefetchOnly) { + storePrefetchedReplies( + state, + action.meta.arg.statusId, + action.payload.context, + ); + } else { + normalizeContext( + state, + action.meta.arg.statusId, + action.payload.context, + ); - if (action.payload.refresh) { - state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + if (action.payload.refresh) { + state.refreshing[action.meta.arg.statusId] = action.payload.refresh; + } } }) + .addCase(showPendingReplies, (state, action) => { + applyPrefetchedReplies(state, action.payload.statusId); + }) + .addCase(clearPendingReplies, (state, action) => { + delete state.pendingReplies[action.payload.statusId]; + }) .addCase(completeContextRefresh, (state, action) => { delete state.refreshing[action.payload.statusId]; }) From 7f5232c377f07c8496b1181fb14b189d13bb8575 Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 6 Oct 2025 18:20:15 +0200 Subject: [PATCH 37/44] [Glitch] Emoji: Remove re: from handleElement in StatusContent Port 9027d604204121808019e4f9b45e5e86565e7f3d to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/components/status_content.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 8b0ec56c82..4cf47adda5 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -304,7 +304,7 @@ class StatusContent extends PureComponent { this.node = c; }; - handleElement = (element, {key, ...props}) => { + handleElement = (element, { key, ...props }) => { if (element instanceof HTMLAnchorElement) { const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); return ( @@ -317,6 +317,8 @@ class StatusContent extends PureComponent { key={key} /> ); + } else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) { + return null; } return undefined; } From 1c278df42488ec1ffb78efab0f90d556bb3f79b3 Mon Sep 17 00:00:00 2001 From: Brad Dunbar Date: Tue, 7 Oct 2025 10:42:15 -0400 Subject: [PATCH 38/44] [Glitch] Resolve typescript eslint warning Port c578a0cb74bdc6492bed58007f8b7971eef43e30 to glitch-soc Signed-off-by: Claire --- app/javascript/flavours/glitch/polyfills/index.ts | 2 +- app/javascript/flavours/glitch/reducers/modal.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/flavours/glitch/polyfills/index.ts b/app/javascript/flavours/glitch/polyfills/index.ts index 0ff0dd7269..1abfe0a935 100644 --- a/app/javascript/flavours/glitch/polyfills/index.ts +++ b/app/javascript/flavours/glitch/polyfills/index.ts @@ -19,7 +19,7 @@ export function loadPolyfills() { return Promise.all([ loadIntlPolyfills(), // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types - needsExtraPolyfills && importExtraPolyfills(), + needsExtraPolyfills ? importExtraPolyfills() : Promise.resolve(), loadEmojiPolyfills(), ]); } diff --git a/app/javascript/flavours/glitch/reducers/modal.ts b/app/javascript/flavours/glitch/reducers/modal.ts index 280695ead7..7144cb4d22 100644 --- a/app/javascript/flavours/glitch/reducers/modal.ts +++ b/app/javascript/flavours/glitch/reducers/modal.ts @@ -41,7 +41,7 @@ const popModal = ( modalType === state.get('stack').get(0)?.get('modalType') ) { return state - .set('ignoreFocus', !!ignoreFocus) + .set('ignoreFocus', ignoreFocus) .update('stack', (stack) => stack.shift()); } else { return state; From f870904e5fa50cdf65de82a49ad9560c0770d3d7 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 7 Oct 2025 17:21:50 +0200 Subject: [PATCH 39/44] [Glitch] Emoji: Bypass legacy emoji normalization Port 3c9b828c714c71598493fe39231175bb156ef6fd to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/components/account_bio.tsx | 2 +- .../glitch/components/display_name/no-domain.tsx | 8 +------- .../glitch/components/display_name/simple.tsx | 8 +------- .../flavours/glitch/components/status_content.jsx | 3 --- .../flavours/glitch/entrypoints/public.tsx | 2 +- .../flavours/glitch/features/emoji/emoji.js | 13 ++++++++++++- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx index 8c221239ef..f620d9c090 100644 --- a/app/javascript/flavours/glitch/components/account_bio.tsx +++ b/app/javascript/flavours/glitch/components/account_bio.tsx @@ -61,7 +61,7 @@ export const AccountBio: React.FC = ({ if (!account) { return ''; } - return isModernEmojiEnabled() ? account.note : account.note_emojified; + return account.note_emojified; }); const extraEmojis = useAppSelector((state) => { const account = state.accounts.get(accountId); diff --git a/app/javascript/flavours/glitch/components/display_name/no-domain.tsx b/app/javascript/flavours/glitch/components/display_name/no-domain.tsx index 5320fba090..ee6e84050c 100644 --- a/app/javascript/flavours/glitch/components/display_name/no-domain.tsx +++ b/app/javascript/flavours/glitch/components/display_name/no-domain.tsx @@ -2,8 +2,6 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; import classNames from 'classnames'; -import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; - import { AnimateEmojiProvider } from '../emoji/context'; import { EmojiHTML } from '../emoji/html'; import { Skeleton } from '../skeleton'; @@ -24,11 +22,7 @@ export const DisplayNameWithoutDomain: FC< {account ? ( diff --git a/app/javascript/flavours/glitch/components/display_name/simple.tsx b/app/javascript/flavours/glitch/components/display_name/simple.tsx index 901b0b8fd4..29d9ee217b 100644 --- a/app/javascript/flavours/glitch/components/display_name/simple.tsx +++ b/app/javascript/flavours/glitch/components/display_name/simple.tsx @@ -1,7 +1,5 @@ import type { ComponentPropsWithoutRef, FC } from 'react'; -import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; - import { EmojiHTML } from '../emoji/html'; import type { DisplayNameProps } from './index'; @@ -19,11 +17,7 @@ export const DisplayNameSimple: FC< diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 4cf47adda5..d3c48dcbe9 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -84,9 +84,6 @@ const isLinkMisleading = (link) => { * @returns {string} */ export function getStatusContent(status) { - if (isModernEmojiEnabled()) { - return status.getIn(['translation', 'content']) || status.get('content'); - } return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml'); } diff --git a/app/javascript/flavours/glitch/entrypoints/public.tsx b/app/javascript/flavours/glitch/entrypoints/public.tsx index 105b877bc7..f4300bc119 100644 --- a/app/javascript/flavours/glitch/entrypoints/public.tsx +++ b/app/javascript/flavours/glitch/entrypoints/public.tsx @@ -70,7 +70,7 @@ function loaded() { }; document.querySelectorAll('.emojify').forEach((content) => { - content.innerHTML = emojify(content.innerHTML); + content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system. }); document diff --git a/app/javascript/flavours/glitch/features/emoji/emoji.js b/app/javascript/flavours/glitch/features/emoji/emoji.js index 55fc382a5d..cc4948a6c6 100644 --- a/app/javascript/flavours/glitch/features/emoji/emoji.js +++ b/app/javascript/flavours/glitch/features/emoji/emoji.js @@ -1,5 +1,6 @@ import Trie from 'substring-trie'; +import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; import { assetHost } from 'flavours/glitch/utils/config'; import { autoPlayGif, useSystemEmojiFont } from '../../initial_state'; @@ -148,7 +149,17 @@ const emojifyNode = (node, customEmojis) => { } }; -const emojify = (str, customEmojis = {}) => { +/** + * Legacy emoji processing function. + * @param {string} str + * @param {object} customEmojis + * @param {boolean} force If true, always emojify even if modern emoji is enabled + * @returns {string} + */ +const emojify = (str, customEmojis = {}, force = false) => { + if (isModernEmojiEnabled() && !force) { + return str; + } const wrapper = document.createElement('div'); wrapper.innerHTML = str; From fc1b407d89abf595118a0555cbdca333a38fba53 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 7 Oct 2025 17:22:00 +0200 Subject: [PATCH 40/44] [Glitch] Emoji: Compare history modal Port e02ea3e1109b717ffc7fc178ec62eb460cab9703 to glitch-soc Signed-off-by: Claire --- .../ui/components/compare_history_modal.jsx | 85 ++++++++++--------- 1 file changed, 47 insertions(+), 38 deletions(-) diff --git a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx index 7e84a578c2..78dddf2de0 100644 --- a/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx +++ b/app/javascript/flavours/glitch/features/ui/components/compare_history_modal.jsx @@ -15,6 +15,8 @@ import InlineAccount from 'flavours/glitch/components/inline_account'; import MediaAttachments from 'flavours/glitch/components/media_attachments'; import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp'; import emojify from 'flavours/glitch/features/emoji/emoji'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; +import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context'; const mapStateToProps = (state, { statusId }) => ({ language: state.getIn(['statuses', statusId, 'language']), @@ -51,8 +53,8 @@ class CompareHistoryModal extends PureComponent { return obj; }, {}); - const content = { __html: emojify(currentVersion.get('content'), emojiMap) }; - const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) }; + const content = emojify(currentVersion.get('content'), emojiMap); + const spoilerContent = emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap); const formattedDate = ; const formattedName = ; @@ -65,43 +67,50 @@ class CompareHistoryModal extends PureComponent { return (
-
- - {label} -
- -
-
- {currentVersion.get('spoiler_text').length > 0 && ( - <> -
-
- - )} - -
- - {!!currentVersion.get('poll') && ( -
-
    - {currentVersion.getIn(['poll', 'options']).map(option => ( -
  • - - - -
  • - ))} -
-
- )} - - + +
+ + {label}
-
+ +
+
+ {currentVersion.get('spoiler_text').length > 0 && ( + <> + +
+ + )} + + + + {!!currentVersion.get('poll') && ( +
+
    + {currentVersion.getIn(['poll', 'options']).map(option => ( +
  • + + + +
  • + ))} +
+
+ )} + + +
+
+
); } From c52473eebc8e5d8c887bb9cafdb9c21120a3939d Mon Sep 17 00:00:00 2001 From: diondiondion Date: Tue, 7 Oct 2025 18:43:40 +0200 Subject: [PATCH 41/44] [Glitch] Ensure Fetch-all-replies snackbar is shown at the bottom of the screen Port e4c3854ae8332bc3273e2949b050bd25205c5404 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/features/status/index.jsx | 2 +- .../flavours/glitch/styles/components.scss | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/app/javascript/flavours/glitch/features/status/index.jsx b/app/javascript/flavours/glitch/features/status/index.jsx index d4dfe40747..bc625fa9fd 100644 --- a/app/javascript/flavours/glitch/features/status/index.jsx +++ b/app/javascript/flavours/glitch/features/status/index.jsx @@ -635,7 +635,7 @@ class Status extends ImmutablePureComponent { /> -
+
{ancestors} diff --git a/app/javascript/flavours/glitch/styles/components.scss b/app/javascript/flavours/glitch/styles/components.scss index 6461d5f2b2..0457f7515e 100644 --- a/app/javascript/flavours/glitch/styles/components.scss +++ b/app/javascript/flavours/glitch/styles/components.scss @@ -3218,18 +3218,23 @@ a.account__display-name { .column__alert { position: sticky; - bottom: 1rem; + bottom: 0; z-index: 10; box-sizing: border-box; display: grid; width: 100%; max-width: 360px; - padding-inline: 10px; - margin-top: 1rem; - margin-inline: auto; + padding: 1rem; + margin: auto auto 0; + overflow: clip; + + &:empty { + padding: 0; + } @media (max-width: #{$mobile-menu-breakpoint - 1}) { - bottom: 4rem; + // Compensate for mobile menubar + bottom: var(--mobile-bottom-nav-height); } & > * { From c4ef050eb6bb48c0ee12c920375a2224b8d21376 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 13:11:25 +0200 Subject: [PATCH 42/44] [Glitch] Emoji: Account page Port 6abda76d13b46c82741de8618e2c141b29fe5355 to glitch-soc Signed-off-by: Claire --- .../{account.tsx => account/index.tsx} | 8 +- .../glitch/components/account_bio.tsx | 27 ++----- .../glitch/components/account_fields.tsx | 70 +++++++++++----- .../flavours/glitch/components/emoji/html.tsx | 16 +++- .../glitch/components/hover_card_account.tsx | 13 ++- .../glitch/components/status/handled_link.tsx | 31 +++++++ .../glitch/components/verified_badge.tsx | 31 ++++++- .../components/account_header.tsx | 51 +----------- .../flavours/glitch/hooks/useLinks.ts | 7 ++ app/javascript/flavours/glitch/utils/html.ts | 80 ++++++++++--------- 10 files changed, 196 insertions(+), 138 deletions(-) rename app/javascript/flavours/glitch/components/{account.tsx => account/index.tsx} (97%) diff --git a/app/javascript/flavours/glitch/components/account.tsx b/app/javascript/flavours/glitch/components/account/index.tsx similarity index 97% rename from app/javascript/flavours/glitch/components/account.tsx rename to app/javascript/flavours/glitch/components/account/index.tsx index 826d3c3ebb..bcb84f2f4e 100644 --- a/app/javascript/flavours/glitch/components/account.tsx +++ b/app/javascript/flavours/glitch/components/account/index.tsx @@ -4,6 +4,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { blockAccount, @@ -33,7 +34,7 @@ import { me } from 'flavours/glitch/initial_state'; import type { MenuItem } from 'flavours/glitch/models/dropdown_menu'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; -import { Permalink } from './permalink'; +import { Permalink } from '../permalink'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, @@ -333,9 +334,10 @@ export const Account: React.FC = ({ {account && withBio && (account.note.length > 0 ? ( -
) : (
diff --git a/app/javascript/flavours/glitch/components/account_bio.tsx b/app/javascript/flavours/glitch/components/account_bio.tsx index f620d9c090..fe692151a6 100644 --- a/app/javascript/flavours/glitch/components/account_bio.tsx +++ b/app/javascript/flavours/glitch/components/account_bio.tsx @@ -6,10 +6,9 @@ import { useLinks } from 'flavours/glitch/hooks/useLinks'; import { useAppSelector } from '../store'; import { isModernEmojiEnabled } from '../utils/environment'; -import type { OnElementHandler } from '../utils/html'; import { EmojiHTML } from './emoji/html'; -import { HandledLink } from './status/handled_link'; +import { useElementHandledLink } from './status/handled_link'; interface AccountBioProps { className: string; @@ -38,23 +37,9 @@ export const AccountBio: React.FC = ({ [showDropdown, accountId], ); - const handleLink = useCallback( - (element, { key, ...props }) => { - if (element instanceof HTMLAnchorElement) { - return ( - - ); - } - return undefined; - }, - [accountId], - ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: showDropdown ? accountId : undefined, + }); const note = useAppSelector((state) => { const account = state.accounts.get(accountId); @@ -77,9 +62,9 @@ export const AccountBio: React.FC = ({ htmlString={note} extraEmojis={extraEmojis} className={classNames(className, 'translate')} - onClickCapture={isModernEmojiEnabled() ? undefined : handleClick} + onClickCapture={handleClick} ref={handleNodeChange} - onElement={handleLink} + {...htmlHandlers} /> ); }; diff --git a/app/javascript/flavours/glitch/components/account_fields.tsx b/app/javascript/flavours/glitch/components/account_fields.tsx index 768eb1fa4b..422dcc4b89 100644 --- a/app/javascript/flavours/glitch/components/account_fields.tsx +++ b/app/javascript/flavours/glitch/components/account_fields.tsx @@ -1,42 +1,70 @@ +import { useIntl } from 'react-intl'; + import classNames from 'classnames'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { Icon } from 'flavours/glitch/components/icon'; -import { useLinks } from 'flavours/glitch/hooks/useLinks'; import type { Account } from 'flavours/glitch/models/account'; -export const AccountFields: React.FC<{ - fields: Account['fields']; - limit: number; -}> = ({ fields, limit = -1 }) => { - const handleClick = useLinks(); +import { CustomEmojiProvider } from './emoji/context'; +import { EmojiHTML } from './emoji/html'; +import { useElementHandledLink } from './status/handled_link'; + +export const AccountFields: React.FC> = ({ + fields, + emojis, +}) => { + const intl = useIntl(); + const htmlHandlers = useElementHandledLink(); if (fields.size === 0) { return null; } return ( -
- {fields.take(limit).map((pair, i) => ( -
-
+ {fields.map((pair, i) => ( +
+ -
- {pair.get('verified_at') && ( - - )} - + {pair.verified_at && ( + + + + )}{' '} +
))} -
+ ); }; + +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; diff --git a/app/javascript/flavours/glitch/components/emoji/html.tsx b/app/javascript/flavours/glitch/components/emoji/html.tsx index dea3894265..b4689101d5 100644 --- a/app/javascript/flavours/glitch/components/emoji/html.tsx +++ b/app/javascript/flavours/glitch/components/emoji/html.tsx @@ -4,7 +4,10 @@ import classNames from 'classnames'; import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types'; import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; -import type { OnElementHandler } from '@/flavours/glitch/utils/html'; +import type { + OnAttributeHandler, + OnElementHandler, +} from '@/flavours/glitch/utils/html'; import { htmlStringToComponents } from '@/flavours/glitch/utils/html'; import { polymorphicForwardRef } from '@/types/polymorphic'; @@ -16,6 +19,7 @@ interface EmojiHTMLProps { extraEmojis?: CustomEmojiMapArg; className?: string; onElement?: OnElementHandler; + onAttribute?: OnAttributeHandler; } export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( @@ -26,14 +30,19 @@ export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( as: asProp = 'div', // Rename for syntax highlighting className = '', onElement, + onAttribute, ...props }, ref, ) => { const contents = useMemo( () => - htmlStringToComponents(htmlString, { onText: textToEmojis, onElement }), - [htmlString, onElement], + htmlStringToComponents(htmlString, { + onText: textToEmojis, + onElement, + onAttribute, + }), + [htmlString, onAttribute, onElement], ); return ( @@ -60,6 +69,7 @@ export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( extraEmojis, className, onElement, + onAttribute, ...rest } = props; const Wrapper = asElement ?? 'div'; diff --git a/app/javascript/flavours/glitch/components/hover_card_account.tsx b/app/javascript/flavours/glitch/components/hover_card_account.tsx index 58ce247c58..66231f3154 100644 --- a/app/javascript/flavours/glitch/components/hover_card_account.tsx +++ b/app/javascript/flavours/glitch/components/hover_card_account.tsx @@ -23,6 +23,8 @@ import { domain } from 'flavours/glitch/initial_state'; import { getAccountHidden } from 'flavours/glitch/selectors/accounts'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; +import { useLinks } from '../hooks/useLinks'; + export const HoverCardAccount = forwardRef< HTMLDivElement, { accountId?: string } @@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef< !isMutual && !isFollower; + const handleClick = useLinks(); + return (
- + +
+ +
+ {note && note.length > 0 && (
diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx index ee41321283..b3a1137645 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -1,7 +1,10 @@ +import { useCallback } from 'react'; import type { ComponentProps, FC } from 'react'; import { Link } from 'react-router-dom'; +import type { OnElementHandler } from '@/flavours/glitch/utils/html'; + export interface HandledLinkProps { href: string; text: string; @@ -77,3 +80,31 @@ export const HandledLink: FC> = ({ return text; } }; + +export const useElementHandledLink = ({ + hashtagAccountId, + mentionAccountId, +}: { + hashtagAccountId?: string; + mentionAccountId?: string; +} = {}) => { + const onElement = useCallback( + (element, { key, ...props }) => { + if (element instanceof HTMLAnchorElement) { + return ( + + ); + } + return undefined; + }, + [hashtagAccountId, mentionAccountId], + ); + return { onElement }; +}; diff --git a/app/javascript/flavours/glitch/components/verified_badge.tsx b/app/javascript/flavours/glitch/components/verified_badge.tsx index 626cc500d6..dffa57ef24 100644 --- a/app/javascript/flavours/glitch/components/verified_badge.tsx +++ b/app/javascript/flavours/glitch/components/verified_badge.tsx @@ -1,10 +1,17 @@ +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; +import { isModernEmojiEnabled } from '../utils/environment'; +import type { OnAttributeHandler } from '../utils/html'; + import { Icon } from './icon'; const domParser = new DOMParser(); const stripRelMe = (html: string) => { + if (isModernEmojiEnabled()) { + return html; + } const document = domParser.parseFromString(html, 'text/html').documentElement; document.querySelectorAll('a[rel]').forEach((link) => { @@ -15,7 +22,23 @@ const stripRelMe = (html: string) => { }); const body = document.querySelector('body'); - return body ? { __html: body.innerHTML } : undefined; + return body?.innerHTML ?? ''; +}; + +const onAttribute: OnAttributeHandler = (name, value, tagName) => { + if (name === 'rel' && tagName === 'a') { + if (value === 'me') { + return null; + } + return [ + name, + value + .split(' ') + .filter((x) => x !== 'me') + .join(' '), + ]; + } + return undefined; }; interface Props { @@ -24,6 +47,10 @@ interface Props { export const VerifiedBadge: React.FC = ({ link }) => ( - + ); diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx index fe42d6486f..074a403b24 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx +++ b/app/javascript/flavours/glitch/features/account_timeline/components/account_header.tsx @@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet'; import { NavLink } from 'react-router-dom'; import { AccountBio } from '@/flavours/glitch/components/account_bio'; +import { AccountFields } from '@/flavours/glitch/components/account_fields'; 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 LockIcon from '@/material-icons/400-24px/lock.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react'; @@ -190,14 +190,6 @@ const titleFromAccount = (account: Account) => { return `${prefix} (@${acct})`; }; -const dateFormatOptions: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', -}; - export const AccountHeader: React.FC<{ accountId: string; hideTabs?: boolean; @@ -895,46 +887,7 @@ export const AccountHeader: React.FC<{
- {fields.map((pair, i) => ( -
-
- -
- {pair.verified_at && ( - - - - )}{' '} - -
-
- ))} +
diff --git a/app/javascript/flavours/glitch/hooks/useLinks.ts b/app/javascript/flavours/glitch/hooks/useLinks.ts index ab9ac4ef47..14c6e2e8f6 100644 --- a/app/javascript/flavours/glitch/hooks/useLinks.ts +++ b/app/javascript/flavours/glitch/hooks/useLinks.ts @@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit'; import { openURL } from 'flavours/glitch/actions/search'; import { useAppDispatch } from 'flavours/glitch/store'; +import { isModernEmojiEnabled } from '../utils/environment'; + const isMentionClick = (element: HTMLAnchorElement) => element.classList.contains('mention') && !element.classList.contains('hashtag'); @@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => { const handleClick = useCallback( (e: React.MouseEvent) => { + // Exit early if modern emoji is enabled, as this is handled by HandledLink. + if (isModernEmojiEnabled()) { + return; + } + const target = (e.target as HTMLElement).closest('a'); if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) { diff --git a/app/javascript/flavours/glitch/utils/html.ts b/app/javascript/flavours/glitch/utils/html.ts index bbda1b7be3..dd2865f2e6 100644 --- a/app/javascript/flavours/glitch/utils/html.ts +++ b/app/javascript/flavours/glitch/utils/html.ts @@ -41,18 +41,22 @@ export type OnElementHandler< extra: Arg, ) => React.ReactNode; +export type OnAttributeHandler< + Arg extends Record = Record, +> = ( + name: string, + value: string, + tagName: string, + extra: Arg, +) => [string, unknown] | undefined | null; + export interface HTMLToStringOptions< Arg extends Record = Record, > { maxDepth?: number; onText?: (text: string, extra: Arg) => React.ReactNode; onElement?: OnElementHandler; - onAttribute?: ( - name: string, - value: string, - tagName: string, - extra: Arg, - ) => [string, unknown] | null; + onAttribute?: OnAttributeHandler; allowedTags?: AllowedTagsType; extraArgs?: Arg; } @@ -140,44 +144,44 @@ export function htmlStringToComponents>( // Custom attribute handler. if (onAttribute) { - const result = onAttribute( - name, - attr.value, - node.tagName.toLowerCase(), - extraArgs, - ); + const result = onAttribute(name, attr.value, tagName, extraArgs); + // Rewrite this attribute. if (result) { const [cbName, value] = result; props[cbName] = value; - } - } else { - // Check global attributes first, then tag-specific ones. - const globalAttr = globalAttributes[name]; - const tagAttr = tagInfo.attributes?.[name]; - - // Exit if neither global nor tag-specific attribute is allowed. - if (!globalAttr && !tagAttr) { + continue; + } else if (result === null) { + // Explicitly remove this attribute. continue; } - - // Rename if needed. - if (typeof tagAttr === 'string') { - name = tagAttr; - } else if (typeof globalAttr === 'string') { - name = globalAttr; - } - - let value: string | boolean | number = attr.value; - - // Handle boolean attributes. - if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; - } - - props[name] = value; } + + // Check global attributes first, then tag-specific ones. + const globalAttr = globalAttributes[name]; + const tagAttr = tagInfo.attributes?.[name]; + + // Exit if neither global nor tag-specific attribute is allowed. + if (!globalAttr && !tagAttr) { + continue; + } + + // Rename if needed. + if (typeof tagAttr === 'string') { + name = tagAttr; + } else if (typeof globalAttr === 'string') { + name = globalAttr; + } + + let value: string | boolean | number = attr.value; + + // Handle boolean attributes. + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } + + props[name] = value; } // If onElement is provided, use it to create the element. From c08a874ba9ebd9bc491bfa31cbb8d7130f9bbd9f Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 16:18:11 +0200 Subject: [PATCH 43/44] [Glitch] Emoji: Statuses Port 0c1ca6c969fcc2ba1c6ad17ff1a94a4ee8711f75 to glitch-soc Signed-off-by: Claire --- .../glitch/components/content_warning.tsx | 55 +++++++++++++------ .../flavours/glitch/components/poll.tsx | 6 +- .../flavours/glitch/components/status.jsx | 4 +- .../glitch/components/status/handled_link.tsx | 9 +-- .../glitch/components/status_banner.tsx | 7 ++- .../compose/components/edit_indicator.jsx | 4 +- .../compose/components/reply_indicator.jsx | 4 +- .../directory/components/account_card.tsx | 8 ++- .../glitch/features/emoji/normalize.ts | 6 ++ .../flavours/glitch/features/emoji/types.ts | 3 +- .../components/account_authorize.jsx | 16 ++++-- .../components/embedded_status.tsx | 28 ++++------ .../components/embedded_status_content.tsx | 45 ++++++++++----- .../status/components/detailed_status.tsx | 18 +++--- 14 files changed, 128 insertions(+), 85 deletions(-) diff --git a/app/javascript/flavours/glitch/components/content_warning.tsx b/app/javascript/flavours/glitch/components/content_warning.tsx index 2312d69c27..f3535b9fb5 100644 --- a/app/javascript/flavours/glitch/components/content_warning.tsx +++ b/app/javascript/flavours/glitch/components/content_warning.tsx @@ -1,25 +1,48 @@ +import type { List } from 'immutable'; + +import type { CustomEmoji } from '../models/custom_emoji'; +import type { Status } from '../models/status'; + +import { EmojiHTML } from './emoji/html'; import type { IconName } from './media_icon'; import { MediaIcon } from './media_icon'; import { StatusBanner, BannerVariant } from './status_banner'; export const ContentWarning: React.FC<{ - text: string; + status: Status; expanded?: boolean; onClick?: () => void; icons?: IconName[]; -}> = ({ text, expanded, onClick, icons }) => ( - - {icons?.map((icon) => ( - = ({ status, expanded, onClick, icons }) => { + const hasSpoiler = !!status.get('spoiler_text'); + if (!hasSpoiler) { + return null; + } + + const text = + status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml'); + if (typeof text !== 'string' || text.length === 0) { + return null; + } + + return ( + + {icons?.map((icon) => ( + + ))} + } /> - ))} - - -); + + ); +}; diff --git a/app/javascript/flavours/glitch/components/poll.tsx b/app/javascript/flavours/glitch/components/poll.tsx index 851d0f02f9..2dde24fe7c 100644 --- a/app/javascript/flavours/glitch/components/poll.tsx +++ b/app/javascript/flavours/glitch/components/poll.tsx @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { animated, useSpring } from '@react-spring/web'; import escapeTextContentForBrowser from 'escape-html'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import { openModal } from 'flavours/glitch/actions/modal'; import { fetchPoll, vote } from 'flavours/glitch/actions/polls'; @@ -305,10 +306,11 @@ const PollOption: React.FC = (props) => { )} - {!!voted && ( diff --git a/app/javascript/flavours/glitch/components/status.jsx b/app/javascript/flavours/glitch/components/status.jsx index 4362b70437..335e5e20b6 100644 --- a/app/javascript/flavours/glitch/components/status.jsx +++ b/app/javascript/flavours/glitch/components/status.jsx @@ -118,7 +118,7 @@ class Status extends ImmutablePureComponent { prepend: PropTypes.string, withDismiss: PropTypes.bool, isQuotedPost: PropTypes.bool, - shouldHighlightOnMount: PropTypes.bool, + shouldHighlightOnMount: PropTypes.bool, getScrollPosition: PropTypes.func, updateScrollBottom: PropTypes.func, expanded: PropTypes.bool, @@ -739,7 +739,7 @@ class Status extends ImmutablePureComponent { )} - {status.get('spoiler_text').length > 0 && } + {expanded && ( <> diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx index b3a1137645..c153053b23 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -83,14 +83,15 @@ export const HandledLink: FC> = ({ export const useElementHandledLink = ({ hashtagAccountId, - mentionAccountId, + hrefToMentionAccountId, }: { hashtagAccountId?: string; - mentionAccountId?: string; + hrefToMentionAccountId?: (href: string) => string | undefined; } = {}) => { const onElement = useCallback( (element, { key, ...props }) => { if (element instanceof HTMLAnchorElement) { + const mentionId = hrefToMentionAccountId?.(element.href); return ( ); } return undefined; }, - [hashtagAccountId, mentionAccountId], + [hashtagAccountId, hrefToMentionAccountId], ); return { onElement }; }; diff --git a/app/javascript/flavours/glitch/components/status_banner.tsx b/app/javascript/flavours/glitch/components/status_banner.tsx index e11b2c9279..a1d200133f 100644 --- a/app/javascript/flavours/glitch/components/status_banner.tsx +++ b/app/javascript/flavours/glitch/components/status_banner.tsx @@ -3,6 +3,8 @@ import { useCallback, useRef, useId } from 'react'; import { FormattedMessage } from 'react-intl'; +import { AnimateEmojiProvider } from './emoji/context'; + export enum BannerVariant { Warning = 'warning', Filter = 'filter', @@ -34,8 +36,7 @@ export const StatusBanner: React.FC<{ return ( // Element clicks are passed on to button - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
)} -
+ ); }; diff --git a/app/javascript/flavours/glitch/features/compose/components/edit_indicator.jsx b/app/javascript/flavours/glitch/features/compose/components/edit_indicator.jsx index bccb716dae..88c58774d9 100644 --- a/app/javascript/flavours/glitch/features/compose/components/edit_indicator.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/edit_indicator.jsx @@ -49,9 +49,7 @@ export const EditIndicator = () => { {(status.get('poll') || status.get('media_attachments').size > 0) && ( diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx index d88fb5ef93..048b23c775 100644 --- a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.jsx @@ -34,9 +34,7 @@ export const ReplyIndicator = () => { {(status.get('poll') || status.get('media_attachments').size > 0) && ( diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx index b07928573e..ebfea75218 100644 --- a/app/javascript/flavours/glitch/features/directory/components/account_card.tsx +++ b/app/javascript/flavours/glitch/features/directory/components/account_card.tsx @@ -1,5 +1,6 @@ import { FormattedMessage } from 'react-intl'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import { Avatar } from 'flavours/glitch/components/avatar'; import { DisplayName } from 'flavours/glitch/components/display_name'; import { FollowButton } from 'flavours/glitch/components/follow_button'; @@ -42,9 +43,10 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => { {account.get('note').length > 0 && ( -
)} diff --git a/app/javascript/flavours/glitch/features/emoji/normalize.ts b/app/javascript/flavours/glitch/features/emoji/normalize.ts index f9d725521b..8934f9a20d 100644 --- a/app/javascript/flavours/glitch/features/emoji/normalize.ts +++ b/app/javascript/flavours/glitch/features/emoji/normalize.ts @@ -154,6 +154,12 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { if (!extraEmojis) { return null; } + if (Array.isArray(extraEmojis)) { + return extraEmojis.reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); + } if (!isList(extraEmojis)) { return extraEmojis; } diff --git a/app/javascript/flavours/glitch/features/emoji/types.ts b/app/javascript/flavours/glitch/features/emoji/types.ts index 792be277a2..e90831e44f 100644 --- a/app/javascript/flavours/glitch/features/emoji/types.ts +++ b/app/javascript/flavours/glitch/features/emoji/types.ts @@ -56,7 +56,8 @@ export type EmojiStateMap = LimitedCache; export type CustomEmojiMapArg = | ExtraCustomEmojiMap - | ImmutableList; + | ImmutableList + | CustomEmoji[]; export type ExtraCustomEmojiMap = Record< string, diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx index 877d84c632..079b742c5a 100644 --- a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx +++ b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.jsx @@ -8,10 +8,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; -import { Avatar } from '../../../components/avatar'; -import { DisplayName } from '../../../components/display_name'; -import { IconButton } from '../../../components/icon_button'; -import { Permalink } from '../../../components/permalink'; +import { Avatar } from '@/flavours/glitch/components/avatar'; +import { DisplayName } from '@/flavours/glitch/components/display_name'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; +import { Permalink } from '@/flavours/glitch/components/permalink'; const messages = defineMessages({ authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, @@ -29,7 +30,6 @@ class AccountAuthorize extends ImmutablePureComponent { render () { const { intl, account, onAuthorize, onReject } = this.props; - const content = { __html: account.get('note_emojified') }; return (
@@ -39,7 +39,11 @@ class AccountAuthorize extends ImmutablePureComponent { -
+
diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx index b11962e38e..4db1dfa0da 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status.tsx @@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom'; import type { List as ImmutableList, RecordOf } from 'immutable'; +import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses'; import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context'; import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react'; import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react'; @@ -18,7 +19,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; import { EmbeddedStatusContent } from './embedded_status_content'; -export type Mention = RecordOf<{ url: string; acct: string }>; +export type Mention = RecordOf; export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ statusId, @@ -86,12 +87,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({ } // Assign status attributes to variables with a forced type, as status is not yet properly typed - const contentHtml = status.get('contentHtml') as string; - const contentWarning = status.get('spoilerHtml') as string; + const hasContentWarning = !!status.get('spoiler_text'); const poll = status.get('poll'); - const language = status.get('language') as string; - const mentions = status.get('mentions') as ImmutableList; - const expanded = !status.get('hidden') || !contentWarning; + const expanded = !status.get('hidden') || !hasContentWarning; const mediaAttachmentsSize = ( status.get('media_attachments') as ImmutableList ).size; @@ -109,20 +107,16 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
- {contentWarning && ( - - )} + - {(!contentWarning || expanded) && ( + {(!hasContentWarning || expanded) && ( )} diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx index 855e160fac..3cb1e12ed4 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; @@ -6,16 +6,22 @@ import type { List } from 'immutable'; import type { History } from 'history'; +import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; +import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; +import type { Status } from '@/flavours/glitch/models/status'; +import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; + import type { Mention } from './embedded_status'; const handleMentionClick = ( history: History, - mention: Mention, + mention: ApiMentionJSON, e: MouseEvent, ) => { if (e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); - history.push(`/@${mention.get('acct')}`); + history.push(`/@${mention.acct}`); } }; @@ -31,16 +37,26 @@ const handleHashtagClick = ( }; export const EmbeddedStatusContent: React.FC<{ - content: string; - mentions: List; - language: string; + status: Status; className?: string; -}> = ({ content, mentions, language, className }) => { +}> = ({ status, className }) => { const history = useHistory(); + const mentions = useMemo( + () => (status.get('mentions') as List).toJS(), + [status], + ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: status.get('account') as string | undefined, + hrefToMentionAccountId(href) { + const mention = mentions.find((item) => item.url === href); + return mention?.id; + }, + }); + const handleContentRef = useCallback( (node: HTMLDivElement | null) => { - if (!node) { + if (!node || isModernEmojiEnabled()) { return; } @@ -53,7 +69,7 @@ export const EmbeddedStatusContent: React.FC<{ link.classList.add('status-link'); - const mention = mentions.find((item) => link.href === item.get('url')); + const mention = mentions.find((item) => link.href === item.url); if (mention) { link.addEventListener( @@ -61,8 +77,8 @@ export const EmbeddedStatusContent: React.FC<{ handleMentionClick.bind(null, history, mention), false, ); - link.setAttribute('title', `@${mention.get('acct')}`); - link.setAttribute('href', `/@${mention.get('acct')}`); + link.setAttribute('title', `@${mention.acct}`); + link.setAttribute('href', `/@${mention.acct}`); } else if ( link.textContent.startsWith('#') || link.previousSibling?.textContent?.endsWith('#') @@ -83,11 +99,12 @@ export const EmbeddedStatusContent: React.FC<{ ); return ( -
); }; diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx index 6c7d719cd2..a43744f9fe 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.tsx @@ -429,17 +429,13 @@ export const DetailedStatus: React.FC<{ /> )} - {status.get('spoiler_text').length > 0 && - (!matchedFilters || showDespiteFilter) && ( - - )} + {(!matchedFilters || showDespiteFilter) && ( + + )} {expanded && ( <> From 62fc92dfd865b9b1fc51e5926363b8cfe46a9112 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 17:07:01 +0200 Subject: [PATCH 44/44] [Glitch] Emoji: Announcements Port babb7b2b9d13d6c9041edfe3ab6fe9fba708709c to glitch-soc Co-authored-by: diondiondion Signed-off-by: Claire --- .../glitch/api_types/announcements.ts | 28 +++++ .../glitch/features/emoji/normalize.ts | 16 +-- .../flavours/glitch/features/emoji/types.ts | 3 +- .../components/announcements/announcement.tsx | 119 ++++++++++++++++++ .../components/announcements/index.tsx | 118 +++++++++++++++++ .../components/announcements/reactions.tsx | 111 ++++++++++++++++ .../glitch/features/home_timeline/index.jsx | 4 +- 7 files changed, 388 insertions(+), 11 deletions(-) create mode 100644 app/javascript/flavours/glitch/api_types/announcements.ts create mode 100644 app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx create mode 100644 app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx create mode 100644 app/javascript/flavours/glitch/features/home_timeline/components/announcements/reactions.tsx diff --git a/app/javascript/flavours/glitch/api_types/announcements.ts b/app/javascript/flavours/glitch/api_types/announcements.ts new file mode 100644 index 0000000000..03e8922d8f --- /dev/null +++ b/app/javascript/flavours/glitch/api_types/announcements.ts @@ -0,0 +1,28 @@ +// See app/serializers/rest/announcement_serializer.rb + +import type { ApiCustomEmojiJSON } from './custom_emoji'; +import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses'; + +export interface ApiAnnouncementJSON { + id: string; + content: string; + starts_at: null | string; + ends_at: null | string; + all_day: boolean; + published_at: string; + updated_at: null | string; + read: boolean; + mentions: ApiMentionJSON[]; + statuses: ApiStatusJSON[]; + tags: ApiTagJSON[]; + emojis: ApiCustomEmojiJSON[]; + reactions: ApiAnnouncementReactionJSON[]; +} + +export interface ApiAnnouncementReactionJSON { + name: string; + count: number; + me: boolean; + url?: string; + static_url?: string; +} diff --git a/app/javascript/flavours/glitch/features/emoji/normalize.ts b/app/javascript/flavours/glitch/features/emoji/normalize.ts index 8934f9a20d..f0a502dcb5 100644 --- a/app/javascript/flavours/glitch/features/emoji/normalize.ts +++ b/app/javascript/flavours/glitch/features/emoji/normalize.ts @@ -160,15 +160,15 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { {}, ); } - if (!isList(extraEmojis)) { - return extraEmojis; + if (isList(extraEmojis)) { + return extraEmojis + .toJS() + .reduce( + (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), + {}, + ); } - return extraEmojis - .toJSON() - .reduce( - (acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }), - {}, - ); + return extraEmojis; } function hexStringToNumbers(hexString: string): number[] { diff --git a/app/javascript/flavours/glitch/features/emoji/types.ts b/app/javascript/flavours/glitch/features/emoji/types.ts index e90831e44f..b9c53e0697 100644 --- a/app/javascript/flavours/glitch/features/emoji/types.ts +++ b/app/javascript/flavours/glitch/features/emoji/types.ts @@ -57,7 +57,8 @@ export type EmojiStateMap = LimitedCache; export type CustomEmojiMapArg = | ExtraCustomEmojiMap | ImmutableList - | CustomEmoji[]; + | CustomEmoji[] + | ApiCustomEmojiJSON[]; export type ExtraCustomEmojiMap = Record< string, diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx new file mode 100644 index 0000000000..1bc8cdb9da --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/announcement.tsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import type { FC } from 'react'; + +import { FormattedDate, FormattedMessage } from 'react-intl'; + +import type { ApiAnnouncementJSON } from '@/flavours/glitch/api_types/announcements'; +import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context'; +import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; + +import { ReactionsBar } from './reactions'; + +export interface IAnnouncement extends ApiAnnouncementJSON { + contentHtml: string; +} + +interface AnnouncementProps { + announcement: IAnnouncement; + selected: boolean; +} + +export const Announcement: FC = ({ + announcement, + selected, +}) => { + const [unread, setUnread] = useState(!announcement.read); + useEffect(() => { + // Only update `unread` marker once the announcement is out of view + if (!selected && unread !== !announcement.read) { + setUnread(!announcement.read); + } + }, [announcement.read, selected, unread]); + + return ( + + + + + {' · '} + + + + + + + + + {unread && } + + ); +}; + +const Timestamp: FC> = ({ + announcement, +}) => { + const startsAt = announcement.starts_at && new Date(announcement.starts_at); + const endsAt = announcement.ends_at && new Date(announcement.ends_at); + const now = new Date(); + const hasTimeRange = startsAt && endsAt; + const skipTime = announcement.all_day; + + if (hasTimeRange) { + const skipYear = + startsAt.getFullYear() === endsAt.getFullYear() && + endsAt.getFullYear() === now.getFullYear(); + const skipEndDate = + startsAt.getDate() === endsAt.getDate() && + startsAt.getMonth() === endsAt.getMonth() && + startsAt.getFullYear() === endsAt.getFullYear(); + return ( + <> + {' '} + -{' '} + + + ); + } + const publishedAt = new Date(announcement.published_at); + return ( + + ); +}; diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx new file mode 100644 index 0000000000..6ce56beebb --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/index.tsx @@ -0,0 +1,118 @@ +import { useCallback, useState } from 'react'; +import type { FC } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import type { Map, List } from 'immutable'; + +import ReactSwipeableViews from 'react-swipeable-views'; + +import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context'; +import { IconButton } from '@/flavours/glitch/components/icon_button'; +import LegacyAnnouncements from '@/flavours/glitch/features/getting_started/containers/announcements_container'; +import { mascot, reduceMotion } from '@/flavours/glitch/initial_state'; +import { createAppSelector, useAppSelector } from '@/flavours/glitch/store'; +import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; +import elephantUIPlane from '@/images/elephant_ui_plane.svg'; +import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; +import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; + +import type { IAnnouncement } from './announcement'; +import { Announcement } from './announcement'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, + previous: { id: 'lightbox.previous', defaultMessage: 'Previous' }, + next: { id: 'lightbox.next', defaultMessage: 'Next' }, +}); + +const announcementSelector = createAppSelector( + [(state) => state.announcements as Map>>], + (announcements) => + (announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [], +); + +export const ModernAnnouncements: FC = () => { + const intl = useIntl(); + + const announcements = useAppSelector(announcementSelector); + const emojis = useAppSelector((state) => state.custom_emojis); + + const [index, setIndex] = useState(0); + const handleChangeIndex = useCallback( + (idx: number) => { + setIndex(idx % announcements.length); + }, + [announcements.length], + ); + const handleNextIndex = useCallback(() => { + setIndex((prevIndex) => (prevIndex + 1) % announcements.length); + }, [announcements.length]); + const handlePrevIndex = useCallback(() => { + setIndex((prevIndex) => + prevIndex === 0 ? announcements.length - 1 : prevIndex - 1, + ); + }, [announcements.length]); + + if (announcements.length === 0) { + return null; + } + + return ( +
+ + +
+ + + {announcements + .map((announcement, idx) => ( + + )) + .reverse()} + + + + {announcements.length > 1 && ( +
+ + + {index + 1} / {announcements.length} + + +
+ )} +
+
+ ); +}; + +export const Announcements = isModernEmojiEnabled() + ? ModernAnnouncements + : LegacyAnnouncements; diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/announcements/reactions.tsx b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/reactions.tsx new file mode 100644 index 0000000000..9efd14e0bc --- /dev/null +++ b/app/javascript/flavours/glitch/features/home_timeline/components/announcements/reactions.tsx @@ -0,0 +1,111 @@ +import { useCallback, useMemo } from 'react'; +import type { FC, HTMLAttributes } from 'react'; + +import classNames from 'classnames'; + +import type { AnimatedProps } from '@react-spring/web'; +import { animated, useTransition } from '@react-spring/web'; + +import { + addReaction, + removeReaction, +} from '@/flavours/glitch/actions/announcements'; +import type { ApiAnnouncementReactionJSON } from '@/flavours/glitch/api_types/announcements'; +import { AnimatedNumber } from '@/flavours/glitch/components/animated_number'; +import { Emoji } from '@/flavours/glitch/components/emoji'; +import { Icon } from '@/flavours/glitch/components/icon'; +import EmojiPickerDropdown from '@/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container'; +import { isUnicodeEmoji } from '@/flavours/glitch/features/emoji/utils'; +import { useAppDispatch } from '@/flavours/glitch/store'; +import AddIcon from '@/material-icons/400-24px/add.svg?react'; + +export const ReactionsBar: FC<{ + reactions: ApiAnnouncementReactionJSON[]; + id: string; +}> = ({ reactions, id }) => { + const visibleReactions = useMemo( + () => reactions.filter((x) => x.count > 0), + [reactions], + ); + + const dispatch = useAppDispatch(); + const handleEmojiPick = useCallback( + (emoji: { native: string }) => { + dispatch(addReaction(id, emoji.native.replaceAll(/:/g, ''))); + }, + [dispatch, id], + ); + + const transitions = useTransition(visibleReactions, { + from: { + scale: 0, + }, + enter: { + scale: 1, + }, + leave: { + scale: 0, + }, + keys: visibleReactions.map((x) => x.name), + }); + + return ( +
+ {transitions(({ scale }, reaction) => ( + `scale(${s})`) }} + id={id} + /> + ))} + + {visibleReactions.length < 8 && ( + } + /> + )} +
+ ); +}; + +const Reaction: FC<{ + reaction: ApiAnnouncementReactionJSON; + id: string; + style: AnimatedProps>['style']; +}> = ({ id, reaction, style }) => { + const dispatch = useAppDispatch(); + const handleClick = useCallback(() => { + if (reaction.me) { + dispatch(removeReaction(id, reaction.name)); + } else { + dispatch(addReaction(id, reaction.name)); + } + }, [dispatch, id, reaction.me, reaction.name]); + + const code = isUnicodeEmoji(reaction.name) + ? reaction.name + : `:${reaction.name}:`; + + return ( + + + + + + + + + ); +}; diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.jsx b/app/javascript/flavours/glitch/features/home_timeline/index.jsx index 68832a7408..76d4037ca6 100644 --- a/app/javascript/flavours/glitch/features/home_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/home_timeline/index.jsx @@ -14,7 +14,6 @@ import { SymbolLogo } from 'flavours/glitch/components/logo'; import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements'; import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge'; import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator'; -import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container'; import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context'; import { criticalUpdatesPending } from 'flavours/glitch/initial_state'; import { withBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint'; @@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container'; import { ColumnSettings } from './components/column_settings'; import { CriticalUpdateBanner } from './components/critical_update_banner'; +import { Announcements } from './components/announcements'; const messages = defineMessages({ title: { id: 'column.home', defaultMessage: 'Home' }, @@ -164,7 +164,7 @@ class HomeTimeline extends PureComponent { pinned={pinned} multiColumn={multiColumn} extraButton={announcementsButton} - appendContent={hasAnnouncements && showAnnouncements && } + appendContent={hasAnnouncements && showAnnouncements && } >