From 8efcdc04eb9c45995a1172f6c3be944418d91c43 Mon Sep 17 00:00:00 2001 From: "Tan, Kian-ting" Date: Wed, 28 Jan 2026 17:56:29 +0000 Subject: [PATCH 01/12] Add `nan-TW` to interface languages (#34923) --- app/helpers/languages_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index dbf56f45a0..cbf5638ae4 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -233,6 +233,7 @@ module LanguagesHelper 'es-AR': 'Español (Argentina)', 'es-MX': 'Español (México)', 'fr-CA': 'Français (Canadien)', + 'nan-TW': '臺語 (Hô-ló話)', 'pt-BR': 'Português (Brasil)', 'pt-PT': 'Português (Portugal)', 'sr-Latn': 'Srpski (latinica)', From 9079a75574426e1e4712d5c9bba9af09af2e85a7 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 29 Jan 2026 10:01:36 +0100 Subject: [PATCH 02/12] Profile redesign: Featured tags (#37645) --- .../mastodon/components/mini_card/list.tsx | 156 +--------------- .../mastodon/components/tags/style.module.css | 2 +- .../mastodon/components/tags/tag.tsx | 15 +- .../mastodon/components/tags/tags.tsx | 42 +++-- .../account_timeline/components/tabs.tsx | 7 +- .../account_timeline/v2/featured_tags.tsx | 124 +++++++++++++ .../features/account_timeline/v2/index.tsx | 2 + .../account_timeline/v2/styles.module.scss | 22 +++ app/javascript/mastodon/hooks/useOverflow.ts | 172 ++++++++++++++++++ app/javascript/mastodon/locales/en.json | 1 + app/javascript/mastodon/selectors/accounts.ts | 56 +++++- app/javascript/mastodon/utils/types.ts | 2 + 12 files changed, 414 insertions(+), 187 deletions(-) create mode 100644 app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx create mode 100644 app/javascript/mastodon/hooks/useOverflow.ts diff --git a/app/javascript/mastodon/components/mini_card/list.tsx b/app/javascript/mastodon/components/mini_card/list.tsx index 318c584953..9b5c859cf4 100644 --- a/app/javascript/mastodon/components/mini_card/list.tsx +++ b/app/javascript/mastodon/components/mini_card/list.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; import type { FC, Key, MouseEventHandler } from 'react'; import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { useOverflow } from '@/mastodon/hooks/useOverflow'; + import { MiniCard } from '.'; import type { MiniCardProps } from '.'; import classes from './styles.module.css'; @@ -66,156 +67,3 @@ export const MiniCardList: FC = ({ ); }; - -function useOverflow() { - const [hiddenIndex, setHiddenIndex] = useState(-1); - const [hiddenCount, setHiddenCount] = useState(0); - const [maxWidth, setMaxWidth] = useState('none'); - - // This is the item container element. - const listRef = useRef(null); - - // The main recalculation function. - const handleRecalculate = useCallback(() => { - const listEle = listRef.current; - if (!listEle) return; - - const reset = () => { - setHiddenIndex(-1); - setHiddenCount(0); - setMaxWidth('none'); - }; - - // Calculate the width via the parent element, minus the more button, minus the padding. - const maxWidth = - (listEle.parentElement?.offsetWidth ?? 0) - - (listEle.nextElementSibling?.scrollWidth ?? 0) - - 4; - if (maxWidth <= 0) { - reset(); - return; - } - - // Iterate through children until we exceed max width. - let visible = 0; - let index = 0; - let totalWidth = 0; - for (const child of listEle.children) { - if (child instanceof HTMLElement) { - const rightOffset = child.offsetLeft + child.offsetWidth; - if (rightOffset <= maxWidth) { - visible += 1; - totalWidth = rightOffset; - } else { - break; - } - } - index++; - } - - // All are visible, so remove max-width restriction. - if (visible === listEle.children.length) { - reset(); - return; - } - - // Set the width to avoid wrapping, and set hidden count. - setHiddenIndex(index); - setHiddenCount(listEle.children.length - visible); - setMaxWidth(totalWidth); - }, []); - - // Set up observers to watch for size and content changes. - const resizeObserverRef = useRef(null); - const mutationObserverRef = useRef(null); - - // Helper to get or create the resize observer. - const resizeObserver = useCallback(() => { - const observer = (resizeObserverRef.current ??= new ResizeObserver( - handleRecalculate, - )); - return observer; - }, [handleRecalculate]); - - // Iterate through children and observe them for size changes. - const handleChildrenChange = useCallback(() => { - const listEle = listRef.current; - const observer = resizeObserver(); - - if (listEle) { - for (const child of listEle.children) { - if (child instanceof HTMLElement) { - observer.observe(child); - } - } - } - handleRecalculate(); - }, [handleRecalculate, resizeObserver]); - - // Helper to get or create the mutation observer. - const mutationObserver = useCallback(() => { - const observer = (mutationObserverRef.current ??= new MutationObserver( - handleChildrenChange, - )); - return observer; - }, [handleChildrenChange]); - - // Set up observers. - const handleObserve = useCallback(() => { - if (wrapperRef.current) { - resizeObserver().observe(wrapperRef.current); - } - if (listRef.current) { - mutationObserver().observe(listRef.current, { childList: true }); - handleChildrenChange(); - } - }, [handleChildrenChange, mutationObserver, resizeObserver]); - - // Watch the wrapper for size changes, and recalculate when it resizes. - const wrapperRef = useRef(null); - const wrapperRefCallback = useCallback( - (node: HTMLElement | null) => { - if (node) { - wrapperRef.current = node; - handleObserve(); - } - }, - [handleObserve], - ); - - // If there are changes to the children, recalculate which are visible. - const listRefCallback = useCallback( - (node: HTMLElement | null) => { - if (node) { - listRef.current = node; - handleObserve(); - } - }, - [handleObserve], - ); - - useEffect(() => { - handleObserve(); - - return () => { - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect(); - resizeObserverRef.current = null; - } - if (mutationObserverRef.current) { - mutationObserverRef.current.disconnect(); - mutationObserverRef.current = null; - } - }; - }, [handleObserve]); - - return { - hiddenCount, - hasOverflow: hiddenCount > 0, - wrapperRef: wrapperRefCallback, - hiddenIndex, - maxWidth, - listRef: listRefCallback, - recalculate: handleRecalculate, - }; -} diff --git a/app/javascript/mastodon/components/tags/style.module.css b/app/javascript/mastodon/components/tags/style.module.css index 1492b67c88..f3c507b644 100644 --- a/app/javascript/mastodon/components/tags/style.module.css +++ b/app/javascript/mastodon/components/tags/style.module.css @@ -3,7 +3,7 @@ border: 1px solid var(--color-border-primary); appearance: none; background: none; - padding: 8px; + padding: 6px 8px; transition: all 0.2s ease-in-out; color: var(--color-text-primary); display: inline-flex; diff --git a/app/javascript/mastodon/components/tags/tag.tsx b/app/javascript/mastodon/components/tags/tag.tsx index 4dd4b89b55..8192854327 100644 --- a/app/javascript/mastodon/components/tags/tag.tsx +++ b/app/javascript/mastodon/components/tags/tag.tsx @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; +import type { OmitUnion } from '@/mastodon/utils/types'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import type { IconProp } from '../icon'; @@ -23,7 +24,7 @@ export interface TagProps { export const Tag = forwardRef< HTMLButtonElement, - TagProps & ComponentPropsWithoutRef<'button'> + OmitUnion, TagProps> >(({ name, active, icon, className, children, ...props }, ref) => { if (!name) { return null; @@ -34,6 +35,7 @@ export const Tag = forwardRef< type='button' ref={ref} className={classNames(className, classes.tag, active && classes.active)} + aria-pressed={active} > {icon && } {typeof name === 'string' ? `#${name}` : name} @@ -45,10 +47,13 @@ Tag.displayName = 'Tag'; export const EditableTag = forwardRef< HTMLSpanElement, - TagProps & { - onRemove: () => void; - removeIcon?: IconProp; - } & ComponentPropsWithoutRef<'span'> + OmitUnion< + ComponentPropsWithoutRef<'span'>, + TagProps & { + onRemove: () => void; + removeIcon?: IconProp; + } + > >( ( { diff --git a/app/javascript/mastodon/components/tags/tags.tsx b/app/javascript/mastodon/components/tags/tags.tsx index c1c120def7..a5b1f9f7c3 100644 --- a/app/javascript/mastodon/components/tags/tags.tsx +++ b/app/javascript/mastodon/components/tags/tags.tsx @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { forwardRef, useCallback } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react'; +import classNames from 'classnames'; + import classes from './style.module.css'; import { EditableTag, Tag } from './tag'; import type { TagProps } from './tag'; @@ -17,31 +19,39 @@ export type TagsProps = { | ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>) ); -export const Tags: FC = ({ tags, active, onRemove, ...props }) => { - if (onRemove) { +export const Tags = forwardRef( + ({ tags, active, onRemove, className, ...props }, ref) => { + if (onRemove) { + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); + } + return ( -
+
{tags.map((tag) => ( - ))}
); - } - - return ( -
- {tags.map((tag) => ( - - ))} -
- ); -}; + }, +); +Tags.displayName = 'Tags'; const MappedTag: FC void }> = ({ onRemove, diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index f525264ed4..eeb48c1c53 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import type { NavLinkProps } from 'react-router-dom'; import { NavLink } from 'react-router-dom'; import { isRedesignEnabled } from '../common'; @@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { if (isRedesignEnabled()) { return (
- + @@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
); }; + +const isActive: Required['isActive'] = (match, location) => + match?.url === location.pathname || + (!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`)); diff --git a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx new file mode 100644 index 0000000000..bdcff2c7e9 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { FC, MouseEventHandler } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +import { useParams } from 'react-router'; + +import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags'; +import { useAppHistory } from '@/mastodon/components/router'; +import { Tag } from '@/mastodon/components/tags/tag'; +import { useOverflow } from '@/mastodon/hooks/useOverflow'; +import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { useFilters } from '../hooks/useFilters'; + +import classes from './styles.module.scss'; + +export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { + // Fetch tags. + const featuredTags = useAppSelector((state) => + selectAccountFeaturedTags(state, accountId), + ); + const dispatch = useAppDispatch(); + useEffect(() => { + void dispatch(fetchFeaturedTags({ accountId })); + }, [accountId, dispatch]); + + // Get list of tags with overflow handling. + const [showOverflow, setShowOverflow] = useState(false); + const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } = + useOverflow(); + + // Handle whether to show all tags. + const handleOverflowClick: MouseEventHandler = useCallback(() => { + setShowOverflow(true); + }, []); + + const { onClick, currentTag } = useTagNavigate(); + + if (featuredTags.length === 0) { + return null; + } + + return ( +
+
+ {featuredTags.map(({ id, name }, index) => ( + 0 && index >= hiddenIndex ? '' : undefined} + onClick={onClick} + active={currentTag === name} + data-name={name} + /> + ))} +
+ {!showOverflow && hiddenCount > 0 && ( + + } + /> + )} +
+ ); +}; + +function useTagNavigate() { + // Get current account, tag, and filters. + const { acct, tagged } = useParams<{ acct: string; tagged?: string }>(); + const { boosts, replies } = useFilters(); + + const history = useAppHistory(); + + const handleTagClick: MouseEventHandler = useCallback( + (event) => { + const name = event.currentTarget.getAttribute('data-name'); + if (!name || !acct) { + return; + } + + // Determine whether to navigate to or from the tag. + let url = `/@${acct}/tagged/${encodeURIComponent(name)}`; + if (name === tagged) { + url = `/@${acct}`; + } + + // Append filters. + const params = new URLSearchParams(); + if (boosts) { + params.append('boosts', '1'); + } + if (replies) { + params.append('replies', '1'); + } + + history.push({ + pathname: url, + search: params.toString(), + }); + }, + [acct, tagged, boosts, replies, history], + ); + + return { + onClick: handleTagClick, + currentTag: tagged, + }; +} diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx index 4254e3d7eb..c0a4cf5735 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/index.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -27,6 +27,7 @@ import { AccountHeader } from '../components/account_header'; import { LimitedAccountHint } from '../components/limited_account_hint'; import { useFilters } from '../hooks/useFilters'; +import { FeaturedTags } from './featured_tags'; import { AccountFilters } from './filters'; const emptyList = ImmutableList(); @@ -135,6 +136,7 @@ const Prepend: FC<{ <> + ); diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index a8ba29afa5..c35b46524e 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -35,3 +35,25 @@ align-items: center; } } + +.tagsWrapper { + margin: 0 24px 8px; + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + gap: 8px; +} + +.tagsList { + display: flex; + gap: 4px; + flex-wrap: nowrap; + overflow: hidden; + position: relative; +} + +.tagsListShowAll { + flex-wrap: wrap; + overflow: visible; + max-width: none !important; +} diff --git a/app/javascript/mastodon/hooks/useOverflow.ts b/app/javascript/mastodon/hooks/useOverflow.ts new file mode 100644 index 0000000000..e5a9ab407e --- /dev/null +++ b/app/javascript/mastodon/hooks/useOverflow.ts @@ -0,0 +1,172 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; + +/** + * Calculate and manage overflow of child elements within a container. + * + * To use, wire up the `wrapperRef` to the container element, and the `listRef` to the + * child element that contains the items to be measured. If autoResize is true, + * the list element will have its max-width set to prevent wrapping. The listRef element + * requires both position:relative and overflow:hidden styles to work correctly. + */ +export function useOverflow({ + autoResize, + padding = 4, +}: { autoResize?: boolean; padding?: number } = {}) { + const [hiddenIndex, setHiddenIndex] = useState(-1); + const [hiddenCount, setHiddenCount] = useState(0); + const [maxWidth, setMaxWidth] = useState('none'); + + // This is the item container element. + const listRef = useRef(null); + + // The main recalculation function. + const handleRecalculate = useCallback(() => { + const listEle = listRef.current; + if (!listEle) return; + + const reset = () => { + setHiddenIndex(-1); + setHiddenCount(0); + setMaxWidth('none'); + }; + + // Calculate the width via the parent element, minus the more button, minus the padding. + const maxWidth = + (listEle.parentElement?.offsetWidth ?? 0) - + (listEle.nextElementSibling?.scrollWidth ?? 0) - + padding; + if (maxWidth <= 0) { + reset(); + return; + } + + // Iterate through children until we exceed max width. + let visible = 0; + let index = 0; + let totalWidth = 0; + for (const child of listEle.children) { + if (child instanceof HTMLElement) { + const rightOffset = child.offsetLeft + child.offsetWidth; + if (rightOffset <= maxWidth) { + visible += 1; + totalWidth = rightOffset; + } else { + break; + } + } + index++; + } + + // All are visible, so remove max-width restriction. + if (visible === listEle.children.length) { + reset(); + return; + } + + // Set the width to avoid wrapping, and set hidden count. + setHiddenIndex(index); + setHiddenCount(listEle.children.length - visible); + setMaxWidth(totalWidth); + }, [padding]); + + useEffect(() => { + if (listRef.current && autoResize) { + listRef.current.style.maxWidth = + typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth; + } + }, [autoResize, maxWidth]); + + // Set up observers to watch for size and content changes. + const resizeObserverRef = useRef(null); + const mutationObserverRef = useRef(null); + + // Helper to get or create the resize observer. + const resizeObserver = useCallback(() => { + const observer = (resizeObserverRef.current ??= new ResizeObserver( + handleRecalculate, + )); + return observer; + }, [handleRecalculate]); + + // Iterate through children and observe them for size changes. + const handleChildrenChange = useCallback(() => { + const listEle = listRef.current; + const observer = resizeObserver(); + + if (listEle) { + for (const child of listEle.children) { + if (child instanceof HTMLElement) { + observer.observe(child); + } + } + } + handleRecalculate(); + }, [handleRecalculate, resizeObserver]); + + // Helper to get or create the mutation observer. + const mutationObserver = useCallback(() => { + const observer = (mutationObserverRef.current ??= new MutationObserver( + handleChildrenChange, + )); + return observer; + }, [handleChildrenChange]); + + // Set up observers. + const handleObserve = useCallback(() => { + if (wrapperRef.current) { + resizeObserver().observe(wrapperRef.current); + } + if (listRef.current) { + mutationObserver().observe(listRef.current, { childList: true }); + handleChildrenChange(); + } + }, [handleChildrenChange, mutationObserver, resizeObserver]); + + // Watch the wrapper for size changes, and recalculate when it resizes. + const wrapperRef = useRef(null); + const wrapperRefCallback = useCallback( + (node: HTMLElement | null) => { + if (node) { + wrapperRef.current = node; + handleObserve(); + } + }, + [handleObserve], + ); + + // If there are changes to the children, recalculate which are visible. + const listRefCallback = useCallback( + (node: HTMLElement | null) => { + if (node) { + listRef.current = node; + handleObserve(); + } + }, + [handleObserve], + ); + + useEffect(() => { + handleObserve(); + + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } + if (mutationObserverRef.current) { + mutationObserverRef.current.disconnect(); + mutationObserverRef.current = null; + } + }; + }, [handleObserve]); + + return { + hiddenCount, + hasOverflow: hiddenCount > 0, + wrapperRef: wrapperRefCallback, + hiddenIndex, + maxWidth, + listRef: listRefCallback, + recalculate: handleRecalculate, + }; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 1468848715..c49db6f5f0 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -427,6 +427,7 @@ "featured_carousel.current": "Post {current, number} / {max, number}", "featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}", "featured_carousel.slide": "Post {current, number} of {max, number}", + "featured_tags.more_items": "+{count}", "filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.", "filter_modal.added.context_mismatch_title": "Context mismatch!", "filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.", diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts index f9ba1a76a6..bf608fec4e 100644 --- a/app/javascript/mastodon/selectors/accounts.ts +++ b/app/javascript/mastodon/selectors/accounts.ts @@ -1,12 +1,15 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { Record as ImmutableRecord } from 'immutable'; +import type { Map as ImmutableMap } from 'immutable'; +import { Record as ImmutableRecord, List as ImmutableList } from 'immutable'; import { me } from 'mastodon/initial_state'; import { accountDefaultValues } from 'mastodon/models/account'; import type { Account, AccountShape } from 'mastodon/models/account'; import type { Relationship } from 'mastodon/models/relationship'; +import { createAppSelector } from 'mastodon/store'; import type { RootState } from 'mastodon/store'; +import type { ApiHashtagJSON } from '../api_types/tags'; + const getAccountBase = (state: RootState, id: string) => state.accounts.get(id, null); @@ -33,7 +36,7 @@ const FullAccountFactory = ImmutableRecord({ }); export function makeGetAccount() { - return createSelector( + return createAppSelector( [getAccountBase, getAccountRelationship, getAccountMoved], (base, relationship, moved) => { if (base === null) { @@ -47,23 +50,23 @@ export function makeGetAccount() { ); } -export const getAccountHidden = createSelector( +export const getAccountHidden = createAppSelector( [ - (state: RootState, id: string) => state.accounts.get(id)?.hidden, - (state: RootState, id: string) => + (state, id: string) => state.accounts.get(id)?.hidden, + (state, id: string) => state.relationships.get(id)?.following || state.relationships.get(id)?.requested, - (state: RootState, id: string) => id === me, + (_, id: string) => id === me, ], (hidden, followingOrRequested, isSelf) => { return hidden && !(isSelf || followingOrRequested); }, ); -export const getAccountFamiliarFollowers = createSelector( +export const getAccountFamiliarFollowers = createAppSelector( [ - (state: RootState) => state.accounts, - (state: RootState, id: string) => state.accounts_familiar_followers[id], + (state) => state.accounts, + (state, id: string) => state.accounts_familiar_followers[id], ], (accounts, accounts_familiar_followers) => { if (!accounts_familiar_followers) return null; @@ -72,3 +75,36 @@ export const getAccountFamiliarFollowers = createSelector( .filter((f) => !!f); }, ); + +export type TagType = Omit< + ApiHashtagJSON, + 'history' | 'following' | 'featured' +> & { + accountId: string; + statuses_count: number; + last_status_at: string; +}; + +export const selectAccountFeaturedTags = createAppSelector( + [(state) => state.user_lists, (_, accountId: string) => accountId], + (user_lists, accountId) => { + const list = user_lists.getIn( + ['featured_tags', accountId, 'items'], + ImmutableList(), + ) as ImmutableList>; + return list.toArray().map( + (tag) => + ({ + id: tag.get('id') as string, + name: tag.get('name') as string, + url: tag.get('url') as string, + accountId: tag.get('accountId') as string, + statuses_count: Number.parseInt( + tag.get('statuses_count') as string, + 10, + ), + last_status_at: tag.get('last_status_at') as string, + }) satisfies TagType, + ); + }, +); diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index 019b074813..f51b3ad8b3 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -22,3 +22,5 @@ export type OmitValueType = { }; export type AnyFunction = (...args: never) => unknown; + +export type OmitUnion = TBase & Omit; From 8a42689268c985a2687ad35735707cd498d0b708 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 29 Jan 2026 10:02:42 +0100 Subject: [PATCH 03/12] Prevent account note from appearing on your own profile (#37653) --- .../account_timeline/components/account_header.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 695a05521d..24a21a2011 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -162,11 +162,13 @@ export const AccountHeader: React.FC<{ {!suspendedOrHidden && (
- {me && account.id !== me && isRedesignEnabled() ? ( - - ) : ( - - )} + {me && + account.id !== me && + (isRedesignEnabled() ? ( + + ) : ( + + ))} Date: Thu, 29 Jan 2026 10:06:49 +0100 Subject: [PATCH 04/12] Add initial collections editor page (#37643) --- .../mastodon/api_types/collections.ts | 2 +- .../mastodon/components/form_fields/index.ts | 1 + .../mastodon/features/collections/editor.tsx | 266 ++++++++++++++++++ .../mastodon/features/collections/index.tsx | 22 +- app/javascript/mastodon/features/ui/index.jsx | 7 + .../features/ui/util/async-components.js | 6 + app/javascript/mastodon/locales/en.json | 10 + 7 files changed, 292 insertions(+), 22 deletions(-) create mode 100644 app/javascript/mastodon/features/collections/editor.tsx diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index 954abfae5e..61ed9d9439 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -70,7 +70,7 @@ type CommonPayloadFields = Pick< ApiCollectionJSON, 'name' | 'description' | 'sensitive' | 'discoverable' > & { - tag?: string; + tag_name?: string; }; export interface ApiPatchCollectionPayload extends Partial { diff --git a/app/javascript/mastodon/components/form_fields/index.ts b/app/javascript/mastodon/components/form_fields/index.ts index 2aa8764514..8100d56049 100644 --- a/app/javascript/mastodon/components/form_fields/index.ts +++ b/app/javascript/mastodon/components/form_fields/index.ts @@ -1,3 +1,4 @@ export { TextInputField } from './text_input_field'; export { TextAreaField } from './text_area_field'; +export { ToggleField, PlainToggleField } from './toggle_field'; export { SelectField } from './select_field'; diff --git a/app/javascript/mastodon/features/collections/editor.tsx b/app/javascript/mastodon/features/collections/editor.tsx new file mode 100644 index 0000000000..29659edf9e --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor.tsx @@ -0,0 +1,266 @@ +import { useCallback, useState, useEffect } from 'react'; + +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; +import { useParams, useHistory } from 'react-router-dom'; + +import { isFulfilled } from '@reduxjs/toolkit'; + +import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; +import type { + ApiCollectionJSON, + ApiCreateCollectionPayload, +} from 'mastodon/api_types/collections'; +import { Button } from 'mastodon/components/button'; +import { Column } from 'mastodon/components/column'; +import { ColumnHeader } from 'mastodon/components/column_header'; +import { TextAreaField, ToggleField } from 'mastodon/components/form_fields'; +import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import { createCollection } from 'mastodon/reducers/slices/collections'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; + +const messages = defineMessages({ + edit: { id: 'column.edit_collection', defaultMessage: 'Edit collection' }, + create: { + id: 'column.create_collection', + defaultMessage: 'Create collection', + }, +}); + +const CollectionSettings: React.FC<{ + collection?: ApiCollectionJSON | null; +}> = ({ collection }) => { + const dispatch = useAppDispatch(); + const history = useHistory(); + + const { + id, + name: initialName = '', + description: initialDescription = '', + tag, + discoverable: initialDiscoverable = true, + sensitive: initialSensitive = false, + } = collection ?? {}; + + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(initialDescription); + const [topic, setTopic] = useState(tag?.name ?? ''); + const [discoverable] = useState(initialDiscoverable); + const [sensitive, setSensitive] = useState(initialSensitive); + + const handleNameChange = useCallback( + (event: React.ChangeEvent) => { + setName(event.target.value); + }, + [], + ); + + const handleDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + setDescription(event.target.value); + }, + [], + ); + + const handleTopicChange = useCallback( + (event: React.ChangeEvent) => { + setTopic(event.target.value); + }, + [], + ); + + const handleSensitiveChange = useCallback( + (event: React.ChangeEvent) => { + setSensitive(event.target.checked); + }, + [], + ); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + + if (id) { + // void dispatch( + // updateList({ + // id, + // title, + // exclusive, + // replies_policy: repliesPolicy, + // }), + // ).then(() => { + // return ''; + // }); + } else { + const payload: ApiCreateCollectionPayload = { + name, + description, + discoverable, + sensitive, + }; + if (topic) { + payload.tag_name = topic; + } + void dispatch( + createCollection({ + payload, + }), + ).then((result) => { + if (isFulfilled(result)) { + history.replace( + `/collections/${result.payload.collection.id}/edit`, + ); + history.push(`/collections`); + } + + return ''; + }); + } + }, + [id, dispatch, name, description, topic, discoverable, sensitive, history], + ); + + return ( +
+
+ + } + hint={ + + } + value={name} + onChange={handleNameChange} + maxLength={40} + /> +
+ +
+ + } + hint={ + + } + value={description} + onChange={handleDescriptionChange} + maxLength={100} + /> +
+ +
+ + } + hint={ + + } + value={topic} + onChange={handleTopicChange} + maxLength={40} + /> +
+ +
+ + } + hint={ + + } + checked={sensitive} + onChange={handleSensitiveChange} + /> +
+ +
+ +
+
+ ); +}; + +export const CollectionEditorPage: React.FC<{ + multiColumn?: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { id } = useParams<{ id?: string }>(); + const collection = useAppSelector((state) => + id ? state.collections.collections[id] : undefined, + ); + const isEditMode = !!id; + const isLoading = isEditMode && !collection; + + useEffect(() => { + // if (id) { + // dispatch(fetchCollection(id)); + // } + }, [dispatch, id]); + + const pageTitle = intl.formatMessage(id ? messages.edit : messages.create); + + return ( + + + +
+ {isLoading ? ( + + ) : ( + + )} +
+ + + {pageTitle} + + +
+ ); +}; diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 0b4b4c8d21..1afa43ebb8 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -16,7 +16,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu'; import { Icon } from 'mastodon/components/icon'; import ScrollableList from 'mastodon/components/scrollable_list'; import { - createCollection, fetchAccountCollections, selectMyCollections, } from 'mastodon/reducers/slices/collections'; @@ -67,7 +66,7 @@ const ListItem: React.FC<{ return (
- + {name} @@ -94,24 +93,6 @@ export const Collections: React.FC<{ void dispatch(fetchAccountCollections({ accountId: me })); }, [dispatch, me]); - const addDummyCollection = useCallback( - (event: React.MouseEvent) => { - event.preventDefault(); - - void dispatch( - createCollection({ - payload: { - name: 'Test Collection', - description: 'A useful test collection', - discoverable: true, - sensitive: false, - }, - }), - ); - }, - [dispatch], - ); - const emptyMessage = status === 'error' ? ( diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index c1a3fa895a..5ba78f599a 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -64,6 +64,7 @@ import { ListEdit, ListMembers, Collections, + CollectionsEditor, Blocks, DomainBlocks, Mutes, @@ -229,6 +230,12 @@ class SwitchingColumnsArea extends PureComponent { + {areCollectionsEnabled() && + + } + {areCollectionsEnabled() && + + } {areCollectionsEnabled() && } diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 4d31f6a4c4..7500575d7a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -50,6 +50,12 @@ export function Collections () { ); } +export function CollectionsEditor () { + return import('../../collections/editor').then( + module => ({default: module.CollectionEditorPage}) + ); +} + export function Status () { return import('../../status'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index c49db6f5f0..ecd35d3b19 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -212,21 +212,31 @@ "closed_registrations_modal.find_another_server": "Find another server", "closed_registrations_modal.preamble": "Mastodon is decentralized, so no matter where you create your account, you will be able to follow and interact with anyone on this server. You can even self-host it!", "closed_registrations_modal.title": "Signing up on Mastodon", + "collections.collection_description": "Description", + "collections.collection_name": "Name", + "collections.collection_topic": "Topic", "collections.create_a_collection_hint": "Create a collection to recommend or share your favourite accounts with others.", "collections.create_collection": "Create collection", "collections.delete_collection": "Delete collection", + "collections.description_length_hint": "100 characters limit", "collections.error_loading_collections": "There was an error when trying to load your collections.", + "collections.mark_as_sensitive": "Mark as sensitive", + "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The title will still be visible.", + "collections.name_length_hint": "100 characters limit", "collections.no_collections_yet": "No collections yet.", + "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", "collections.view_collection": "View collection", "column.about": "About", "column.blocks": "Blocked users", "column.bookmarks": "Bookmarks", "column.collections": "My collections", "column.community": "Local timeline", + "column.create_collection": "Create collection", "column.create_list": "Create list", "column.direct": "Private mentions", "column.directory": "Browse profiles", "column.domain_blocks": "Blocked domains", + "column.edit_collection": "Edit collection", "column.edit_list": "Edit list", "column.favourites": "Favorites", "column.firehose": "Live feeds", From bc3871f992179451e18406369d8de81df2fc12d6 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 29 Jan 2026 10:38:57 +0100 Subject: [PATCH 05/12] Fix followers with profile subscription (bell icon) being notified of post edits (#37646) --- app/workers/feed_insert_worker.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index e883daf3ea..b0c9c9181f 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -53,7 +53,7 @@ class FeedInsertWorker def notify?(filter_result) return false if @type != :home || @status.reblog? || (@status.reply? && @status.in_reply_to_account_id != @status.account_id) || - filter_result == :filter + update? || filter_result == :filter Follow.find_by(account: @follower, target_account: @status.account)&.notify? end From 23148dc536bd25b2aea6324e89f6fc85afa569ee Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 29 Jan 2026 05:14:14 -0500 Subject: [PATCH 06/12] Update rubocop to version 1.8.40 (#37628) --- Gemfile.lock | 4 ++-- .../metrics/dimension/tag_servers_dimension.rb | 2 +- app/models/concerns/status/threading_concern.rb | 4 ++-- app/services/notify_service.rb | 2 +- app/workers/move_worker.rb | 14 +++++++------- ...0180608213548_reject_following_blocked_users.rb | 4 ++-- db/migrate/20180812173710_copy_status_stats.rb | 2 +- db/migrate/20181116173541_copy_account_stats.rb | 2 +- .../20220613110711_migrate_custom_filters.rb | 6 +++--- ...190519130537_remove_boosts_widening_audience.rb | 2 +- ...0729171123_fix_custom_filter_keywords_id_seq.rb | 2 +- lib/mastodon/cli/statuses.rb | 4 ++-- 12 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index b050d5ad63..ee26939a26 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -754,7 +754,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.6) - rubocop (1.82.1) + rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -762,7 +762,7 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.48.0, < 2.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) rubocop-ast (1.49.0) diff --git a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb index 29145e1487..ee40d2c9c8 100644 --- a/app/lib/admin/metrics/dimension/tag_servers_dimension.rb +++ b/app/lib/admin/metrics/dimension/tag_servers_dimension.rb @@ -22,7 +22,7 @@ class Admin::Metrics::Dimension::TagServersDimension < Admin::Metrics::Dimension end def sql_query_string - <<-SQL.squish + <<~SQL.squish SELECT accounts.domain, count(*) AS value FROM statuses INNER JOIN accounts ON accounts.id = statuses.account_id diff --git a/app/models/concerns/status/threading_concern.rb b/app/models/concerns/status/threading_concern.rb index 3b0a3cd028..91b3450f94 100644 --- a/app/models/concerns/status/threading_concern.rb +++ b/app/models/concerns/status/threading_concern.rb @@ -49,7 +49,7 @@ module Status::ThreadingConcern end def ancestor_statuses(limit) - Status.find_by_sql([<<-SQL.squish, id: in_reply_to_id, limit: limit]) + Status.find_by_sql([<<~SQL.squish, id: in_reply_to_id, limit: limit]) WITH RECURSIVE search_tree(id, in_reply_to_id, path) AS ( SELECT id, in_reply_to_id, ARRAY[id] @@ -73,7 +73,7 @@ module Status::ThreadingConcern depth += 1 if depth.present? limit += 1 if limit.present? - descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, depth: depth]) + descendants_with_self = Status.find_by_sql([<<~SQL.squish, id: id, limit: limit, depth: depth]) WITH RECURSIVE search_tree(id, path) AS ( SELECT id, ARRAY[id] FROM statuses diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 0c40e7b3a8..2f009d5a23 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -84,7 +84,7 @@ class NotifyService < BaseService # This queries private mentions from the recipient to the sender up in the thread. # This allows up to 100 messages that do not match in the thread, allowing conversations # involving multiple people. - Status.count_by_sql([<<-SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @sender.id, depth_limit: 100]) + Status.count_by_sql([<<~SQL.squish, id: @notification.target_status.in_reply_to_id, recipient_id: @recipient.id, sender_id: @sender.id, depth_limit: 100]) WITH RECURSIVE ancestors(id, in_reply_to_id, mention_id, path, depth) AS ( SELECT s.id, s.in_reply_to_id, m.id, ARRAY[s.id], 0 FROM statuses s diff --git a/app/workers/move_worker.rb b/app/workers/move_worker.rb index eb0ba5e1bb..14cb47a27f 100644 --- a/app/workers/move_worker.rb +++ b/app/workers/move_worker.rb @@ -48,11 +48,11 @@ class MoveWorker source_local_followers .where(account: @target_account.followers.local) .in_batches do |follows| - ListAccount.where(follow: follows).includes(:list).find_each do |list_account| - list_account.list.accounts << @target_account - rescue ActiveRecord::RecordInvalid - nil - end + ListAccount.where(follow: follows).includes(:list).find_each do |list_account| + list_account.list.accounts << @target_account + rescue ActiveRecord::RecordInvalid + nil + end end # Finally, handle the common case of accounts not following the new account @@ -60,8 +60,8 @@ class MoveWorker .where.not(account: @target_account.followers.local) .where.not(account_id: @target_account.id) .in_batches do |follows| - ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id) - num_moved += follows.update_all(target_account_id: @target_account.id) + ListAccount.where(follow: follows).in_batches.update_all(account_id: @target_account.id) + num_moved += follows.update_all(target_account_id: @target_account.id) end num_moved diff --git a/db/migrate/20180608213548_reject_following_blocked_users.rb b/db/migrate/20180608213548_reject_following_blocked_users.rb index 4cb6395469..a82bff62b4 100644 --- a/db/migrate/20180608213548_reject_following_blocked_users.rb +++ b/db/migrate/20180608213548_reject_following_blocked_users.rb @@ -4,14 +4,14 @@ class RejectFollowingBlockedUsers < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - blocked_follows = Follow.find_by_sql(<<-SQL.squish) + blocked_follows = Follow.find_by_sql(<<~SQL.squish) select f.* from follows f inner join blocks b on f.account_id = b.target_account_id and f.target_account_id = b.account_id SQL - domain_blocked_follows = Follow.find_by_sql(<<-SQL.squish) + domain_blocked_follows = Follow.find_by_sql(<<~SQL.squish) select f.* from follows f inner join accounts following on f.account_id = following.id inner join account_domain_blocks b on diff --git a/db/migrate/20180812173710_copy_status_stats.rb b/db/migrate/20180812173710_copy_status_stats.rb index 087b1290db..74c4fe0387 100644 --- a/db/migrate/20180812173710_copy_status_stats.rb +++ b/db/migrate/20180812173710_copy_status_stats.rb @@ -27,7 +27,7 @@ class CopyStatusStats < ActiveRecord::Migration[5.2] say 'Upsert is available, importing counters using the fast method' Status.unscoped.select('id').find_in_batches(batch_size: 5_000) do |statuses| - execute <<-SQL.squish + execute <<~SQL.squish INSERT INTO status_stats (status_id, reblogs_count, favourites_count, created_at, updated_at) SELECT id, reblogs_count, favourites_count, created_at, updated_at FROM statuses diff --git a/db/migrate/20181116173541_copy_account_stats.rb b/db/migrate/20181116173541_copy_account_stats.rb index e5faee0cb5..f80d71b777 100644 --- a/db/migrate/20181116173541_copy_account_stats.rb +++ b/db/migrate/20181116173541_copy_account_stats.rb @@ -31,7 +31,7 @@ class CopyAccountStats < ActiveRecord::Migration[5.2] say 'Upsert is available, importing counters using the fast method' MigrationAccount.unscoped.select('id').find_in_batches(batch_size: 5_000) do |accounts| - execute <<-SQL.squish + execute <<~SQL.squish INSERT INTO account_stats (account_id, statuses_count, following_count, followers_count, created_at, updated_at) SELECT id, statuses_count, following_count, followers_count, created_at, updated_at FROM accounts diff --git a/db/migrate/20220613110711_migrate_custom_filters.rb b/db/migrate/20220613110711_migrate_custom_filters.rb index ea6a9b8c6d..f3b2e01a06 100644 --- a/db/migrate/20220613110711_migrate_custom_filters.rb +++ b/db/migrate/20220613110711_migrate_custom_filters.rb @@ -5,7 +5,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1] # Preserve IDs as much as possible to not confuse existing clients. # As long as this migration is irreversible, we do not have to deal with conflicts. safety_assured do - execute <<-SQL.squish + execute <<~SQL.squish INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at) SELECT id, id, phrase, whole_word, created_at, updated_at FROM custom_filters @@ -16,7 +16,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1] def down # Copy back changes from custom filters guaranteed to be from the old API safety_assured do - execute <<-SQL.squish + execute <<~SQL.squish UPDATE custom_filters SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word FROM custom_filter_keywords @@ -26,7 +26,7 @@ class MigrateCustomFilters < ActiveRecord::Migration[6.1] # Drop every keyword as we can't safely provide a 1:1 mapping safety_assured do - execute <<-SQL.squish + execute <<~SQL.squish TRUNCATE custom_filter_keywords RESTART IDENTITY SQL end diff --git a/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb b/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb index 89a95041ee..8faeba7be0 100644 --- a/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb +++ b/db/post_migrate/20190519130537_remove_boosts_widening_audience.rb @@ -4,7 +4,7 @@ class RemoveBoostsWideningAudience < ActiveRecord::Migration[5.2] disable_ddl_transaction! def up - public_boosts = Status.find_by_sql(<<-SQL.squish) + public_boosts = Status.find_by_sql(<<~SQL.squish) SELECT boost.id FROM statuses AS boost LEFT JOIN statuses AS boosted ON boost.reblog_of_id = boosted.id diff --git a/db/post_migrate/20220729171123_fix_custom_filter_keywords_id_seq.rb b/db/post_migrate/20220729171123_fix_custom_filter_keywords_id_seq.rb index eb437c86c5..edc689a716 100644 --- a/db/post_migrate/20220729171123_fix_custom_filter_keywords_id_seq.rb +++ b/db/post_migrate/20220729171123_fix_custom_filter_keywords_id_seq.rb @@ -7,7 +7,7 @@ class FixCustomFilterKeywordsIdSeq < ActiveRecord::Migration[6.1] # 20220613110711 manually inserts items with set `id` in the database, but # we also need to bump the sequence number, otherwise safety_assured do - execute <<-SQL.squish + execute <<~SQL.squish BEGIN; LOCK TABLE custom_filter_keywords IN EXCLUSIVE MODE; SELECT setval('custom_filter_keywords_id_seq'::regclass, id) FROM custom_filter_keywords ORDER BY id DESC LIMIT 1; diff --git a/lib/mastodon/cli/statuses.rb b/lib/mastodon/cli/statuses.rb index 7188bc970c..df0fcf0fbb 100644 --- a/lib/mastodon/cli/statuses.rb +++ b/lib/mastodon/cli/statuses.rb @@ -52,7 +52,7 @@ module Mastodon::CLI # Skip accounts followed by local accounts clean_followed_sql = 'AND NOT EXISTS (SELECT 1 FROM follows WHERE statuses.account_id = follows.target_account_id)' unless options[:clean_followed] - ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL', [max_id]) + ActiveRecord::Base.connection.exec_insert(<<~SQL.squish, 'SQL', [max_id]) INSERT INTO statuses_to_be_deleted (id) SELECT statuses.id FROM statuses WHERE deleted_at IS NULL AND NOT local AND uri IS NOT NULL AND (id < $1) AND NOT EXISTS (SELECT 1 FROM statuses AS statuses1 WHERE statuses.id = statuses1.in_reply_to_id) @@ -137,7 +137,7 @@ module Mastodon::CLI ActiveRecord::Base.connection.create_table('conversations_to_be_deleted', force: true) - ActiveRecord::Base.connection.exec_insert(<<-SQL.squish, 'SQL') + ActiveRecord::Base.connection.exec_insert(<<~SQL.squish, 'SQL') INSERT INTO conversations_to_be_deleted (id) SELECT id FROM conversations WHERE NOT EXISTS (SELECT 1 FROM statuses WHERE statuses.conversation_id = conversations.id) SQL From 2cea3ccba0106eeece65598701cc7ec528f3173a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:14:44 +0100 Subject: [PATCH 07/12] Update dependency axios to v1.13.4 (#37640) 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 5f954022bd..e6139c3961 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5536,13 +5536,13 @@ __metadata: linkType: hard "axios@npm:^1.4.0": - version: 1.13.3 - resolution: "axios@npm:1.13.3" + version: 1.13.4 + resolution: "axios@npm:1.13.4" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.4" proxy-from-env: "npm:^1.1.0" - checksum: 10c0/86f0770624d9f14a3f8f8738c8b8f7f7fbb7b0d4ad38757db1de2d71007a0311bc597661c5ff4b4a9ee6350c6956a7282e3a281fcdf7b5b32054e35a8801e2ce + checksum: 10c0/474c00b7d71f4de4ad562589dae6b615149df7c2583bbc5ebba96229f3f85bfb0775d23705338df072f12e48d3e85685c065a3cf6855d58968a672d19214c728 languageName: node linkType: hard From 21f8fc808ec14785c1356bb27a1b5a200c71d4ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:59:30 +0100 Subject: [PATCH 08/12] New Crowdin Translations (automated) (#37655) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/el.json | 8 +++++++ app/javascript/mastodon/locales/es-AR.json | 8 +++++++ app/javascript/mastodon/locales/es-MX.json | 8 +++++++ app/javascript/mastodon/locales/es.json | 18 ++++++++++++++ app/javascript/mastodon/locales/gl.json | 8 +++++++ app/javascript/mastodon/locales/nn.json | 28 ++++++++++++++++++++++ app/javascript/mastodon/locales/zh-CN.json | 18 ++++++++++++++ app/javascript/mastodon/locales/zh-TW.json | 8 +++++++ 8 files changed, 104 insertions(+) diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json index 9c0909bc30..bc15c81d4d 100644 --- a/app/javascript/mastodon/locales/el.json +++ b/app/javascript/mastodon/locales/el.json @@ -14,6 +14,7 @@ "about.powered_by": "Αποκεντρωμένα μέσα κοινωνικής δικτύωσης που βασίζονται στο {mastodon}", "about.rules": "Κανόνες διακομιστή", "account.account_note_header": "Προσωπική σημείωση", + "account.activity": "Δραστηριότητα", "account.add_note": "Προσθέστε μια προσωπική σημείωση", "account.add_or_remove_from_list": "Προσθήκη ή Αφαίρεση από λίστες", "account.badges.bot": "Αυτοματοποιημένος", @@ -41,6 +42,12 @@ "account.featured.hashtags": "Ετικέτες", "account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}", "account.featured_tags.last_status_never": "Καμία ανάρτηση", + "account.filters.all": "Όλη η δραστηριότητα", + "account.filters.boosts_toggle": "Εμφάνιση ενισχύσεων", + "account.filters.posts_boosts": "Αναρτήσεις και ενισχύσεις", + "account.filters.posts_only": "Αναρτήσεις", + "account.filters.posts_replies": "Αναρτήσεις και απαντήσεις", + "account.filters.replies_toggle": "Εμφάνιση απαντήσεων", "account.follow": "Ακολούθησε", "account.follow_back": "Ακολούθησε και εσύ", "account.follow_back_short": "Ακολούθησε και εσύ", @@ -1030,6 +1037,7 @@ "tabs_bar.notifications": "Ειδοποιήσεις", "tabs_bar.publish": "Νέα Ανάρτηση", "tabs_bar.search": "Αναζήτηση", + "tag.remove": "Αφαίρεση", "terms_of_service.effective_as_of": "Ενεργό από {date}", "terms_of_service.title": "Όροι Παροχής Υπηρεσιών", "terms_of_service.upcoming_changes_on": "Επερχόμενες αλλαγές στις {date}", diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json index 51449aa5cf..58bca0b248 100644 --- a/app/javascript/mastodon/locales/es-AR.json +++ b/app/javascript/mastodon/locales/es-AR.json @@ -14,6 +14,7 @@ "about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}", "about.rules": "Reglas del servidor", "account.account_note_header": "Nota personal", + "account.activity": "Actividad", "account.add_note": "Agregar una nota personal", "account.add_or_remove_from_list": "Agregar o quitar de las listas", "account.badges.bot": "Automatizada", @@ -41,6 +42,12 @@ "account.featured.hashtags": "Etiquetas", "account.featured_tags.last_status_at": "Último mensaje: {date}", "account.featured_tags.last_status_never": "Sin mensajes", + "account.filters.all": "Toda la actividad", + "account.filters.boosts_toggle": "Mostrar adhesiones", + "account.filters.posts_boosts": "Mensajes y adhesiones", + "account.filters.posts_only": "Mensajes", + "account.filters.posts_replies": "Mensajes y respuestas", + "account.filters.replies_toggle": "Mostrar respuestas", "account.follow": "Seguir", "account.follow_back": "Seguir", "account.follow_back_short": "Seguir", @@ -1030,6 +1037,7 @@ "tabs_bar.notifications": "Notificaciones", "tabs_bar.publish": "Nuevo mensaje", "tabs_bar.search": "Buscar", + "tag.remove": "Quitar", "terms_of_service.effective_as_of": "Efectivo a partir de {date}", "terms_of_service.title": "Términos del servicio", "terms_of_service.upcoming_changes_on": "Próximos cambios el {date}", diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json index 47ef88f7ca..404e3bfa77 100644 --- a/app/javascript/mastodon/locales/es-MX.json +++ b/app/javascript/mastodon/locales/es-MX.json @@ -14,6 +14,7 @@ "about.powered_by": "Medio social descentralizado con tecnología de {mastodon}", "about.rules": "Reglas del servidor", "account.account_note_header": "Nota personal", + "account.activity": "Actividad", "account.add_note": "Añadir una nota personal", "account.add_or_remove_from_list": "Agregar o eliminar de las listas", "account.badges.bot": "Automatizada", @@ -41,6 +42,12 @@ "account.featured.hashtags": "Etiquetas", "account.featured_tags.last_status_at": "Última publicación el {date}", "account.featured_tags.last_status_never": "Sin publicaciones", + "account.filters.all": "Toda la actividad", + "account.filters.boosts_toggle": "Mostrar impulsos", + "account.filters.posts_boosts": "Publicaciones e impulsos", + "account.filters.posts_only": "Publicaciones", + "account.filters.posts_replies": "Publicaciones y respuestas", + "account.filters.replies_toggle": "Mostrar respuestas", "account.follow": "Seguir", "account.follow_back": "Seguir también", "account.follow_back_short": "Seguir también", @@ -1030,6 +1037,7 @@ "tabs_bar.notifications": "Notificaciones", "tabs_bar.publish": "Nueva publicación", "tabs_bar.search": "Buscar", + "tag.remove": "Eliminar", "terms_of_service.effective_as_of": "En vigor a partir del {date}", "terms_of_service.title": "Condiciones del servicio", "terms_of_service.upcoming_changes_on": "Próximos cambios el {date}", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index a4f9c84d67..89172aee9a 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -14,6 +14,8 @@ "about.powered_by": "Redes sociales descentralizadas con tecnología de {mastodon}", "about.rules": "Reglas del servidor", "account.account_note_header": "Nota personal", + "account.activity": "Actividad", + "account.add_note": "Añadir una nota personal", "account.add_or_remove_from_list": "Agregar o eliminar de listas", "account.badges.bot": "Automatizada", "account.badges.group": "Grupo", @@ -27,6 +29,7 @@ "account.direct": "Mención privada a @{name}", "account.disable_notifications": "Dejar de notificarme cuando @{name} publique algo", "account.domain_blocking": "Bloqueando dominio", + "account.edit_note": "Eidtar nota personal", "account.edit_profile": "Editar perfil", "account.edit_profile_short": "Editar", "account.enable_notifications": "Notificarme cuando @{name} publique algo", @@ -39,6 +42,12 @@ "account.featured.hashtags": "Etiquetas", "account.featured_tags.last_status_at": "Última publicación el {date}", "account.featured_tags.last_status_never": "Sin publicaciones", + "account.filters.all": "Toda la actividad", + "account.filters.boosts_toggle": "Mostrar impulsos", + "account.filters.posts_boosts": "Publicaciones e impulsos", + "account.filters.posts_only": "Publicaciones", + "account.filters.posts_replies": "Publicaciones y respuestas", + "account.filters.replies_toggle": "Mostrar respuestas", "account.follow": "Seguir", "account.follow_back": "Seguir también", "account.follow_back_short": "Seguir también", @@ -72,6 +81,14 @@ "account.muting": "Silenciando", "account.mutual": "Os seguís mutuamente", "account.no_bio": "Sin biografía.", + "account.node_modal.callout": "Las notas personales solo son visibles para ti.", + "account.node_modal.edit_title": "Editar nota personal", + "account.node_modal.error_unknown": "No se pudo guardar la nota", + "account.node_modal.field_label": "Nota personal", + "account.node_modal.save": "Guardar", + "account.node_modal.title": "Añadir una nota personal", + "account.note.edit_button": "Editar", + "account.note.title": "Nota personal (visible solo para ti)", "account.open_original_page": "Abrir página original", "account.posts": "Publicaciones", "account.posts_with_replies": "Publicaciones y respuestas", @@ -1020,6 +1037,7 @@ "tabs_bar.notifications": "Notificaciones", "tabs_bar.publish": "Nueva Publicación", "tabs_bar.search": "Buscar", + "tag.remove": "Eliminar", "terms_of_service.effective_as_of": "En vigor a partir del {date}", "terms_of_service.title": "Términos del servicio", "terms_of_service.upcoming_changes_on": "Próximos cambios el {date}", diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json index 32bd315742..c8a76497b2 100644 --- a/app/javascript/mastodon/locales/gl.json +++ b/app/javascript/mastodon/locales/gl.json @@ -14,6 +14,7 @@ "about.powered_by": "Comunicación social descentralizada grazas a {mastodon}", "about.rules": "Regras do servidor", "account.account_note_header": "Nota persoal", + "account.activity": "Actividade", "account.add_note": "Engadir nota persoal", "account.add_or_remove_from_list": "Engadir ou eliminar das listaxes", "account.badges.bot": "Automatizada", @@ -41,6 +42,12 @@ "account.featured.hashtags": "Cancelos", "account.featured_tags.last_status_at": "Última publicación o {date}", "account.featured_tags.last_status_never": "Sen publicacións", + "account.filters.all": "Toda actividade", + "account.filters.boosts_toggle": "Mostrar promocións", + "account.filters.posts_boosts": "Publicacións e promocións", + "account.filters.posts_only": "Publicacións", + "account.filters.posts_replies": "Publicacións e respostas", + "account.filters.replies_toggle": "Mostrar respostas", "account.follow": "Seguir", "account.follow_back": "Seguir tamén", "account.follow_back_short": "Seguir tamén", @@ -1030,6 +1037,7 @@ "tabs_bar.notifications": "Notificacións", "tabs_bar.publish": "Nova publicación", "tabs_bar.search": "Buscar", + "tag.remove": "Retirar", "terms_of_service.effective_as_of": "Con efecto desde o {date}", "terms_of_service.title": "Condicións do Servizo", "terms_of_service.upcoming_changes_on": "Cambios por vir o {date}", diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json index 4b553ffc13..5ff6778dc3 100644 --- a/app/javascript/mastodon/locales/nn.json +++ b/app/javascript/mastodon/locales/nn.json @@ -14,6 +14,8 @@ "about.powered_by": "Desentraliserte sosiale medium drive av {mastodon}", "about.rules": "Tenarreglar", "account.account_note_header": "Personleg notat", + "account.activity": "Aktivitet", + "account.add_note": "Legg til eit personleg notat", "account.add_or_remove_from_list": "Legg til eller fjern frå lister", "account.badges.bot": "Robot", "account.badges.group": "Gruppe", @@ -27,6 +29,7 @@ "account.direct": "Nemn @{name} privat", "account.disable_notifications": "Slutt å varsle meg når @{name} skriv innlegg", "account.domain_blocking": "Blokkerer domenet", + "account.edit_note": "Rediger det personlege notatet", "account.edit_profile": "Rediger profil", "account.edit_profile_short": "Rediger", "account.enable_notifications": "Varsle meg når @{name} skriv innlegg", @@ -39,6 +42,12 @@ "account.featured.hashtags": "Emneknaggar", "account.featured_tags.last_status_at": "Sist nytta {date}", "account.featured_tags.last_status_never": "Ingen innlegg", + "account.filters.all": "All aktivitet", + "account.filters.boosts_toggle": "Vis framhevingar", + "account.filters.posts_boosts": "Innlegg og framhevingar", + "account.filters.posts_only": "Innlegg", + "account.filters.posts_replies": "Innlegg og svar", + "account.filters.replies_toggle": "Vis svar", "account.follow": "Fylg", "account.follow_back": "Fylg tilbake", "account.follow_back_short": "Fylg tilbake", @@ -72,6 +81,14 @@ "account.muting": "Dempa", "account.mutual": "De fylgjer kvarandre", "account.no_bio": "Inga skildring er gjeven.", + "account.node_modal.callout": "Berre du kan sjå personlege notat.", + "account.node_modal.edit_title": "Rediger det personlege notatet", + "account.node_modal.error_unknown": "Klarte ikkje å lagra notatet", + "account.node_modal.field_label": "Personleg notat", + "account.node_modal.save": "Lagre", + "account.node_modal.title": "Legg til eit personleg notat", + "account.note.edit_button": "Rediger", + "account.note.title": "Personleg notat (berre synleg for deg)", "account.open_original_page": "Opne originalsida", "account.posts": "Tut", "account.posts_with_replies": "Tut og svar", @@ -187,6 +204,7 @@ "bundle_modal_error.close": "Lat att", "bundle_modal_error.message": "Noko gjekk gale då denne sida vart lasta.", "bundle_modal_error.retry": "Prøv igjen", + "callout.dismiss": "Avvis", "carousel.current": "Side {current, number} / {max, number}", "carousel.slide": "Side {current, number} av {max, number}", "closed_registrations.other_server_instructions": "Sidan Mastodon er desentralisert kan du lage ein brukar på ein anna tenar og framleis interagere med denne.", @@ -194,9 +212,16 @@ "closed_registrations_modal.find_another_server": "Finn ein annan tenar", "closed_registrations_modal.preamble": "Mastodon er desentralisert, så uansett kvar du opprettar ein konto, vil du kunne fylgje og samhandle med alle på denne tenaren. Du kan til og med ha din eigen tenar!", "closed_registrations_modal.title": "Registrer deg på Mastodon", + "collections.create_a_collection_hint": "Lag ei samling for å tilrå eller dela favorittbrukarkontoane dine med andre.", + "collections.create_collection": "Lag ei samling", + "collections.delete_collection": "Slett samlinga", + "collections.error_loading_collections": "Noko gjekk gale då me prøvde å henta samlingane dine.", + "collections.no_collections_yet": "Du har ingen samlingar enno.", + "collections.view_collection": "Sjå samlinga", "column.about": "Om", "column.blocks": "Blokkerte brukarar", "column.bookmarks": "Bokmerke", + "column.collections": "Samlingane mine", "column.community": "Lokal tidsline", "column.create_list": "Lag liste", "column.direct": "Private omtaler", @@ -454,6 +479,7 @@ "footer.source_code": "Vis kjeldekode", "footer.status": "Status", "footer.terms_of_service": "Brukarvilkår", + "form_field.optional": "(valfritt)", "generic.saved": "Lagra", "getting_started.heading": "Kom i gang", "hashtag.admin_moderation": "Opne moderasjonsgrensesnitt for #{name}", @@ -790,6 +816,7 @@ "privacy.private.short": "Fylgjarar", "privacy.public.long": "Kven som helst på og av Mastodon", "privacy.public.short": "Offentleg", + "privacy.quote.anyone": "{visibility}, det er lov å sitera", "privacy.quote.disabled": "{visibility}, ingen kan sitera", "privacy.quote.limited": "{visibility}, avgrensa sitat", "privacy.unlisted.additional": "Dette er akkurat som offentleg, bortsett frå at innlegga ikkje dukkar opp i direktestraumar eller emneknaggar, i oppdagingar eller Mastodon-søk, sjølv om du har sagt ja til at kontoen skal vera synleg.", @@ -1010,6 +1037,7 @@ "tabs_bar.notifications": "Varsel", "tabs_bar.publish": "Nytt innlegg", "tabs_bar.search": "Søk", + "tag.remove": "Fjern", "terms_of_service.effective_as_of": "I kraft frå {date}", "terms_of_service.title": "Bruksvilkår", "terms_of_service.upcoming_changes_on": "Komande endringar {date}", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 5be74aa9a1..796bb0557a 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -14,6 +14,8 @@ "about.powered_by": "由 {mastodon} 驱动的去中心化社交媒体", "about.rules": "站点规则", "account.account_note_header": "个人备注", + "account.activity": "活动", + "account.add_note": "添加个人备注", "account.add_or_remove_from_list": "从列表中添加或移除", "account.badges.bot": "机器人", "account.badges.group": "群组", @@ -27,6 +29,7 @@ "account.direct": "私下提及 @{name}", "account.disable_notifications": "当 @{name} 发布嘟文时不要通知我", "account.domain_blocking": "正在屏蔽中的域名", + "account.edit_note": "编辑个人备注", "account.edit_profile": "修改个人资料", "account.edit_profile_short": "编辑", "account.enable_notifications": "当 @{name} 发布嘟文时通知我", @@ -39,6 +42,12 @@ "account.featured.hashtags": "话题", "account.featured_tags.last_status_at": "上次发言于 {date}", "account.featured_tags.last_status_never": "暂无嘟文", + "account.filters.all": "所有活动", + "account.filters.boosts_toggle": "显示转嘟", + "account.filters.posts_boosts": "嘟文与转嘟", + "account.filters.posts_only": "嘟文", + "account.filters.posts_replies": "嘟文与回复", + "account.filters.replies_toggle": "显示回复", "account.follow": "关注", "account.follow_back": "回关", "account.follow_back_short": "回关", @@ -72,6 +81,14 @@ "account.muting": "正在静音", "account.mutual": "你们互相关注", "account.no_bio": "未提供描述。", + "account.node_modal.callout": "个人备注仅对您个人可见。", + "account.node_modal.edit_title": "编辑个人备注", + "account.node_modal.error_unknown": "无法保存备注", + "account.node_modal.field_label": "个人备注", + "account.node_modal.save": "保存", + "account.node_modal.title": "添加个人备注", + "account.note.edit_button": "编辑", + "account.note.title": "个人备注(仅对您可见)", "account.open_original_page": "打开原始页面", "account.posts": "嘟文", "account.posts_with_replies": "嘟文和回复", @@ -1020,6 +1037,7 @@ "tabs_bar.notifications": "通知", "tabs_bar.publish": "新嘟文", "tabs_bar.search": "搜索", + "tag.remove": "移除", "terms_of_service.effective_as_of": "自 {date} 起生效", "terms_of_service.title": "服务条款", "terms_of_service.upcoming_changes_on": "{date} 起即将生效的更改", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index c58df666a8..df5ffd6d8a 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -14,6 +14,7 @@ "about.powered_by": "由 {mastodon} 提供之去中心化社群媒體", "about.rules": "伺服器規則", "account.account_note_header": "個人備註", + "account.activity": "活動", "account.add_note": "新增個人備註", "account.add_or_remove_from_list": "自列表中新增或移除", "account.badges.bot": "機器人", @@ -41,6 +42,12 @@ "account.featured.hashtags": "主題標籤", "account.featured_tags.last_status_at": "上次發嘟於 {date}", "account.featured_tags.last_status_never": "沒有嘟文", + "account.filters.all": "所有活動", + "account.filters.boosts_toggle": "顯示轉嘟", + "account.filters.posts_boosts": "嘟文與轉嘟", + "account.filters.posts_only": "嘟文", + "account.filters.posts_replies": "嘟文與回嘟", + "account.filters.replies_toggle": "顯示回嘟", "account.follow": "跟隨", "account.follow_back": "跟隨回去", "account.follow_back_short": "跟隨回去", @@ -1030,6 +1037,7 @@ "tabs_bar.notifications": "通知", "tabs_bar.publish": "新增嘟文", "tabs_bar.search": "搜尋", + "tag.remove": "移除", "terms_of_service.effective_as_of": "{date} 起生效", "terms_of_service.title": "服務條款", "terms_of_service.upcoming_changes_on": "{date} 起即將發生之異動", From 6f53b0b634ee821272d2fc9c73c8ce0df7d63814 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 29 Jan 2026 12:01:40 +0100 Subject: [PATCH 09/12] Implement editing collection settings and deleting collections (#37658) --- app/javascript/mastodon/api/collections.ts | 6 +- .../mastodon/api_types/collections.ts | 2 +- .../mastodon/features/collections/editor.tsx | 40 ++++++---- .../mastodon/features/collections/index.tsx | 7 +- .../confirmation_modals/delete_collection.tsx | 54 +++++++++++++ .../components/confirmation_modals/index.ts | 1 + .../features/ui/components/modal_root.jsx | 2 + app/javascript/mastodon/locales/en.json | 5 +- .../mastodon/reducers/slices/collections.ts | 78 +++++++++++++++---- 9 files changed, 156 insertions(+), 39 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/components/confirmation_modals/delete_collection.tsx diff --git a/app/javascript/mastodon/api/collections.ts b/app/javascript/mastodon/api/collections.ts index 142e303422..8e3ceb7389 100644 --- a/app/javascript/mastodon/api/collections.ts +++ b/app/javascript/mastodon/api/collections.ts @@ -9,7 +9,7 @@ import type { ApiWrappedCollectionJSON, ApiCollectionWithAccountsJSON, ApiCreateCollectionPayload, - ApiPatchCollectionPayload, + ApiUpdateCollectionPayload, ApiCollectionsJSON, } from '../api_types/collections'; @@ -19,7 +19,7 @@ export const apiCreateCollection = (collection: ApiCreateCollectionPayload) => export const apiUpdateCollection = ({ id, ...collection -}: ApiPatchCollectionPayload) => +}: ApiUpdateCollectionPayload) => apiRequestPut( `v1_alpha/collections/${id}`, collection, @@ -29,7 +29,7 @@ export const apiDeleteCollection = (collectionId: string) => apiRequestDelete(`v1_alpha/collections/${collectionId}`); export const apiGetCollection = (collectionId: string) => - apiRequestGet( + apiRequestGet( `v1_alpha/collections/${collectionId}`, ); diff --git a/app/javascript/mastodon/api_types/collections.ts b/app/javascript/mastodon/api_types/collections.ts index 61ed9d9439..c1a17b5dc2 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -73,7 +73,7 @@ type CommonPayloadFields = Pick< tag_name?: string; }; -export interface ApiPatchCollectionPayload extends Partial { +export interface ApiUpdateCollectionPayload extends Partial { id: string; } diff --git a/app/javascript/mastodon/features/collections/editor.tsx b/app/javascript/mastodon/features/collections/editor.tsx index 29659edf9e..138e7764e5 100644 --- a/app/javascript/mastodon/features/collections/editor.tsx +++ b/app/javascript/mastodon/features/collections/editor.tsx @@ -11,6 +11,7 @@ import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import type { ApiCollectionJSON, ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, } from 'mastodon/api_types/collections'; import { Button } from 'mastodon/components/button'; import { Column } from 'mastodon/components/column'; @@ -18,7 +19,11 @@ import { ColumnHeader } from 'mastodon/components/column_header'; import { TextAreaField, ToggleField } from 'mastodon/components/form_fields'; import { TextInputField } from 'mastodon/components/form_fields/text_input_field'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { createCollection } from 'mastodon/reducers/slices/collections'; +import { + createCollection, + fetchCollection, + updateCollection, +} from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; const messages = defineMessages({ @@ -83,16 +88,18 @@ const CollectionSettings: React.FC<{ e.preventDefault(); if (id) { - // void dispatch( - // updateList({ - // id, - // title, - // exclusive, - // replies_policy: repliesPolicy, - // }), - // ).then(() => { - // return ''; - // }); + const payload: ApiUpdateCollectionPayload = { + id, + name, + description, + tag_name: topic, + discoverable, + sensitive, + }; + + void dispatch(updateCollection({ payload })).then(() => { + history.push(`/collections`); + }); } else { const payload: ApiCreateCollectionPayload = { name, @@ -103,6 +110,7 @@ const CollectionSettings: React.FC<{ if (topic) { payload.tag_name = topic; } + void dispatch( createCollection({ payload, @@ -114,8 +122,6 @@ const CollectionSettings: React.FC<{ ); history.push(`/collections`); } - - return ''; }); } }, @@ -198,7 +204,7 @@ const CollectionSettings: React.FC<{ hint={ } checked={sensitive} @@ -232,9 +238,9 @@ export const CollectionEditorPage: React.FC<{ const isLoading = isEditMode && !collection; useEffect(() => { - // if (id) { - // dispatch(fetchCollection(id)); - // } + if (id) { + void dispatch(fetchCollection({ collectionId: id })); + } }, [dispatch, id]); const pageTitle = intl.formatMessage(id ? messages.edit : messages.create); diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 1afa43ebb8..bd1c4f790b 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -48,13 +48,14 @@ const ListItem: React.FC<{ const handleDeleteClick = useCallback(() => { dispatch( openModal({ - modalType: 'CONFIRM_DELETE_LIST', + modalType: 'CONFIRM_DELETE_COLLECTION', modalProps: { - listId: id, + name, + id, }, }), ); - }, [dispatch, id]); + }, [dispatch, id, name]); const menu = useMemo( () => [ diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/delete_collection.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/delete_collection.tsx new file mode 100644 index 0000000000..4bc2374603 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/delete_collection.tsx @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import { defineMessages, useIntl } from 'react-intl'; + +import { useHistory } from 'react-router'; + +import { deleteCollection } from 'mastodon/reducers/slices/collections'; +import { useAppDispatch } from 'mastodon/store'; + +import type { BaseConfirmationModalProps } from './confirmation_modal'; +import { ConfirmationModal } from './confirmation_modal'; + +const messages = defineMessages({ + deleteListTitle: { + id: 'confirmations.delete_collection.title', + defaultMessage: 'Delete "{name}"?', + }, + deleteListMessage: { + id: 'confirmations.delete_collection.message', + defaultMessage: 'This action cannot be undone.', + }, + deleteListConfirm: { + id: 'confirmations.delete_collection.confirm', + defaultMessage: 'Delete', + }, +}); + +export const ConfirmDeleteCollectionModal: React.FC< + { + id: string; + name: string; + } & BaseConfirmationModalProps +> = ({ id, name, onClose }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const history = useHistory(); + + const onConfirm = useCallback(() => { + void dispatch(deleteCollection({ collectionId: id })); + history.push('/collections'); + }, [dispatch, history, id]); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts b/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts index 9aff30eeac..389ad7ea83 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/index.ts @@ -1,6 +1,7 @@ export { ConfirmationModal } from './confirmation_modal'; export { ConfirmDeleteStatusModal } from './delete_status'; export { ConfirmDeleteListModal } from './delete_list'; +export { ConfirmDeleteCollectionModal } from './delete_collection'; export { ConfirmReplyModal, ConfirmEditStatusModal, diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 0458bac93c..30d7578c55 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -29,6 +29,7 @@ import { ConfirmationModal, ConfirmDeleteStatusModal, ConfirmDeleteListModal, + ConfirmDeleteCollectionModal, ConfirmReplyModal, ConfirmEditStatusModal, ConfirmUnblockModal, @@ -57,6 +58,7 @@ export const MODAL_COMPONENTS = { 'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }), 'CONFIRM_DELETE_STATUS': () => Promise.resolve({ default: ConfirmDeleteStatusModal }), 'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }), + 'CONFIRM_DELETE_COLLECTION': () => Promise.resolve({ default: ConfirmDeleteCollectionModal }), 'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }), 'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }), 'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }), diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index ecd35d3b19..49448c4f33 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -221,7 +221,7 @@ "collections.description_length_hint": "100 characters limit", "collections.error_loading_collections": "There was an error when trying to load your collections.", "collections.mark_as_sensitive": "Mark as sensitive", - "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The title will still be visible.", + "collections.mark_as_sensitive_hint": "Hides the collection's description and accounts behind a content warning. The collection name will still be visible.", "collections.name_length_hint": "100 characters limit", "collections.no_collections_yet": "No collections yet.", "collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.", @@ -291,6 +291,9 @@ "confirmations.delete.confirm": "Delete", "confirmations.delete.message": "Are you sure you want to delete this post?", "confirmations.delete.title": "Delete post?", + "confirmations.delete_collection.confirm": "Delete", + "confirmations.delete_collection.message": "This action cannot be undone.", + "confirmations.delete_collection.title": "Delete \"{name}\"?", "confirmations.delete_list.confirm": "Delete", "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", "confirmations.delete_list.title": "Delete list?", diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index 0eb7bfbbcf..6f8637bb2c 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -1,13 +1,17 @@ import { createSlice } from '@reduxjs/toolkit'; +import { importFetchedAccounts } from '@/mastodon/actions/importer'; import { apiCreateCollection, apiGetAccountCollections, - // apiGetCollection, + apiUpdateCollection, + apiGetCollection, + apiDeleteCollection, } from '@/mastodon/api/collections'; import type { ApiCollectionJSON, ApiCreateCollectionPayload, + ApiUpdateCollectionPayload, } from '@/mastodon/api_types/collections'; import { createAppSelector, @@ -59,10 +63,11 @@ const collectionSlice = createSlice({ }; }); - builder.addCase(fetchAccountCollections.fulfilled, (state, actions) => { - const { collections } = actions.payload; + builder.addCase(fetchAccountCollections.fulfilled, (state, action) => { + const { collections } = action.payload; - const collectionsMap: Record = {}; + const collectionsMap: Record = + state.collections; const collectionIds: string[] = []; collections.forEach((collection) => { @@ -72,12 +77,40 @@ const collectionSlice = createSlice({ }); state.collections = collectionsMap; - state.accountCollections[actions.meta.arg.accountId] = { + state.accountCollections[action.meta.arg.accountId] = { collectionIds, status: 'idle', }; }); + /** + * Fetching a single collection + */ + + builder.addCase(fetchCollection.fulfilled, (state, action) => { + const { collection } = action.payload; + state.collections[collection.id] = collection; + }); + + /** + * Updating a collection + */ + + builder.addCase(updateCollection.fulfilled, (state, action) => { + const { collection } = action.payload; + state.collections[collection.id] = collection; + }); + + /** + * Deleting a collection + */ + + builder.addCase(deleteCollection.fulfilled, (state, action) => { + const { collectionId } = action.meta.arg; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete state.collections[collectionId]; + }); + /** * Creating a collection */ @@ -86,6 +119,7 @@ const collectionSlice = createSlice({ const { collection } = actions.payload; state.collections[collection.id] = collection; + if (state.accountCollections[collection.account_id]) { state.accountCollections[collection.account_id]?.collectionIds.unshift( collection.id, @@ -105,13 +139,17 @@ export const fetchAccountCollections = createDataLoadingThunk( ({ accountId }: { accountId: string }) => apiGetAccountCollections(accountId), ); -// To be added soon… -// -// export const fetchCollection = createDataLoadingThunk( -// `${collectionSlice.name}/fetchCollection`, -// ({ collectionId }: { collectionId: string }) => -// apiGetCollection(collectionId), -// ); +export const fetchCollection = createDataLoadingThunk( + `${collectionSlice.name}/fetchCollection`, + ({ collectionId }: { collectionId: string }) => + apiGetCollection(collectionId), + (payload, { dispatch }) => { + if (payload.accounts.length > 0) { + dispatch(importFetchedAccounts(payload.accounts)); + } + return payload; + }, +); export const createCollection = createDataLoadingThunk( `${collectionSlice.name}/createCollection`, @@ -119,6 +157,18 @@ export const createCollection = createDataLoadingThunk( apiCreateCollection(payload), ); +export const updateCollection = createDataLoadingThunk( + `${collectionSlice.name}/updateCollection`, + ({ payload }: { payload: ApiUpdateCollectionPayload }) => + apiUpdateCollection(payload), +); + +export const deleteCollection = createDataLoadingThunk( + `${collectionSlice.name}/deleteCollection`, + ({ collectionId }: { collectionId: string }) => + apiDeleteCollection(collectionId), +); + export const collections = collectionSlice.reducer; /** @@ -136,7 +186,7 @@ export const selectMyCollections = createAppSelector( (state) => state.collections.accountCollections, (state) => state.collections.collections, ], - (me, collectionsByAccountId, collectionsById) => { + (me, collectionsByAccountId, collectionsMap) => { const myCollectionsQuery = collectionsByAccountId[me]; if (!myCollectionsQuery) { @@ -151,7 +201,7 @@ export const selectMyCollections = createAppSelector( return { status, collections: collectionIds - .map((id) => collectionsById[id]) + .map((id) => collectionsMap[id]) .filter((c) => !!c), } satisfies AccountCollectionQuery; }, From 0196c12e7f1e330394fc21ad502b8a2ede28b747 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:48:09 +0100 Subject: [PATCH 10/12] Update dependency dotenv to v17 (#35216) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Claire --- streaming/index.js | 3 ++- streaming/package.json | 2 +- yarn.lock | 11 +++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/streaming/index.js b/streaming/index.js index 2a4d89ffe4..b342d5243d 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -30,7 +30,8 @@ const dotenvFilePath = path.resolve( ); dotenv.config({ - path: dotenvFilePath + path: dotenvFilePath, + quiet: true, }); initializeLogLevel(process.env, environment); diff --git a/streaming/package.json b/streaming/package.json index 7684ed7cc8..7a1685749a 100644 --- a/streaming/package.json +++ b/streaming/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "cors": "^2.8.5", - "dotenv": "^16.0.3", + "dotenv": "^17.0.0", "express": "^5.1.0", "ioredis": "^5.3.2", "jsdom": "^27.0.0", diff --git a/yarn.lock b/yarn.lock index e6139c3961..8ab0bae75e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3005,7 +3005,7 @@ __metadata: "@types/ws": "npm:^8.5.9" bufferutil: "npm:^4.0.7" cors: "npm:^2.8.5" - dotenv: "npm:^16.0.3" + dotenv: "npm:^17.0.0" express: "npm:^5.1.0" globals: "npm:^17.0.0" ioredis: "npm:^5.3.2" @@ -6634,13 +6634,20 @@ __metadata: languageName: node linkType: hard -"dotenv@npm:^16.0.3, dotenv@npm:^16.4.2": +"dotenv@npm:^16.4.2": version: 16.6.1 resolution: "dotenv@npm:16.6.1" checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc languageName: node linkType: hard +"dotenv@npm:^17.0.0": + version: 17.2.3 + resolution: "dotenv@npm:17.2.3" + checksum: 10c0/c884403209f713214a1b64d4d1defa4934c2aa5b0002f5a670ae298a51e3c3ad3ba79dfee2f8df49f01ae74290fcd9acdb1ab1d09c7bfb42b539036108bb2ba0 + languageName: node + linkType: hard + "dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1": version: 1.0.1 resolution: "dunder-proto@npm:1.0.1" From d5d57ac25a9d2ee1795e6adb613b8ac8ee974b1f Mon Sep 17 00:00:00 2001 From: Daniel King Date: Thu, 29 Jan 2026 15:53:51 +0000 Subject: [PATCH 11/12] Add flag to preserve cached media on cleanup (#36200) Co-authored-by: Daniel King --- app/models/media_attachment.rb | 8 ++++ app/models/status.rb | 1 + lib/mastodon/cli/media.rb | 10 ++++- spec/lib/mastodon/cli/media_spec.rb | 60 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 2615eed4e3..c0c768154b 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -214,6 +214,14 @@ class MediaAttachment < ApplicationRecord scope :remote, -> { where.not(remote_url: '') } scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) } scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) } + scope :without_local_interaction, lambda { + where.not(Favourite.joins(:account).merge(Account.local).where(Favourite.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Bookmark.where(Bookmark.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Status.local.where(Status.arel_table[:in_reply_to_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Status.local.where(Status.arel_table[:reblog_of_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Quote.joins(:status).merge(Status.local).where(Quote.arel_table[:quoted_status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + .where.not(Quote.joins(:quoted_status).merge(Status.local).where(Quote.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists) + } attr_accessor :skip_download diff --git a/app/models/status.rb b/app/models/status.rb index a0441c0a36..2b5f8e58a3 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -90,6 +90,7 @@ class Status < ApplicationRecord has_many :local_favorited, -> { merge(Account.local) }, through: :favourites, source: :account has_many :local_reblogged, -> { merge(Account.local) }, through: :reblogs, source: :account has_many :local_bookmarked, -> { merge(Account.local) }, through: :bookmarks, source: :account + has_many :local_replied, -> { merge(Account.local) }, through: :replies, source: :account has_and_belongs_to_many :tags # rubocop:disable Rails/HasAndBelongsToMany diff --git a/lib/mastodon/cli/media.rb b/lib/mastodon/cli/media.rb index 02c9894c36..4a1e757406 100644 --- a/lib/mastodon/cli/media.rb +++ b/lib/mastodon/cli/media.rb @@ -17,6 +17,7 @@ module Mastodon::CLI option :concurrency, type: :numeric, default: 5, aliases: [:c] option :verbose, type: :boolean, default: false, aliases: [:v] option :dry_run, type: :boolean, default: false + option :keep_interacted, type: :boolean, default: false desc 'remove', 'Remove remote media files, headers or avatars' long_desc <<-DESC Removes locally cached copies of media attachments (and optionally profile @@ -26,6 +27,9 @@ module Mastodon::CLI they are removed. In case of avatars and headers, it specifies how old the last webfinger request and update to the user has to be before they are pruned. It defaults to 7 days. + If --keep-interacted is specified, any media attached to a status that + was favourited, bookmarked, quoted, replied to, or reblogged by a local + account will be preserved. If --prune-profiles is specified, only avatars and headers are removed. If --remove-headers is specified, only headers are removed. If --include-follows is specified along with --prune-profiles or @@ -61,7 +65,11 @@ module Mastodon::CLI end unless options[:prune_profiles] || options[:remove_headers] - processed, aggregate = parallelize_with_progress(MediaAttachment.cached.remote.where(created_at: ..time_ago)) do |media_attachment| + attachment_scope = MediaAttachment.cached.remote.where(created_at: ..time_ago) + + attachment_scope = attachment_scope.without_local_interaction if options[:keep_interacted] + + processed, aggregate = parallelize_with_progress(attachment_scope) do |media_attachment| next if media_attachment.file.blank? size = (media_attachment.file_file_size || 0) + (media_attachment.thumbnail_file_size || 0) diff --git a/spec/lib/mastodon/cli/media_spec.rb b/spec/lib/mastodon/cli/media_spec.rb index fa7a3161d0..da66951c3b 100644 --- a/spec/lib/mastodon/cli/media_spec.rb +++ b/spec/lib/mastodon/cli/media_spec.rb @@ -73,6 +73,66 @@ RSpec.describe Mastodon::CLI::Media do expect(media_attachment.reload.thumbnail).to be_blank end end + + context 'with --keep-interacted' do + let(:options) { { keep_interacted: true } } + + let!(:favourited_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:bookmarked_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:replied_to_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:reblogged_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:remote_quoted_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + let!(:remote_quoting_media) { Fabricate(:media_attachment, created_at: 1.month.ago, remote_url: 'https://example.com/image.jpg') } + + before do + local_account = Fabricate(:account, username: 'alice') + remote_account = Fabricate(:account, username: 'bob', domain: 'example.com') + + favourited_status = Fabricate(:status, account: remote_account) + bookmarked_status = Fabricate(:status, account: remote_account) + replied_to_status = Fabricate(:status, account: remote_account) + reblogged_status = Fabricate(:status, account: remote_account) + + favourited_media.update!(status: favourited_status) + bookmarked_media.update!(status: bookmarked_status) + replied_to_media.update!(status: replied_to_status) + reblogged_media.update!(status: reblogged_status) + + local_quoting_status = Fabricate(:status, account: local_account) + remote_quoted_status = Fabricate(:status, account: remote_account) + local_status_being_quoted = Fabricate(:status, account: local_account) + remote_quoting_status = Fabricate(:status, account: remote_account) + + remote_quoted_media.update!(status: remote_quoted_status) + remote_quoting_media.update!(status: remote_quoting_status) + + non_interacted_status = Fabricate(:status, account: remote_account) + + media_attachment.update(status: non_interacted_status) + + Fabricate(:favourite, account: local_account, status: favourited_status) + Fabricate(:bookmark, account: local_account, status: bookmarked_status) + Fabricate(:status, account: local_account, in_reply_to_id: replied_to_status.id) + Fabricate(:status, account: local_account, reblog: reblogged_status) + Fabricate(:quote, account: local_account, status: local_quoting_status, quoted_status: remote_quoted_status) + Fabricate(:quote, account: remote_account, status: remote_quoting_status, quoted_status: local_status_being_quoted) + end + + it 'keeps media associated with statuses that have been favourited, bookmarked, replied to, or reblogged by a local account' do + expect { subject } + .to output_results('Removed 1') + + expect(favourited_media.reload.file).to be_present + expect(bookmarked_media.reload.file).to be_present + expect(replied_to_media.reload.file).to be_present + expect(reblogged_media.reload.file).to be_present + expect(remote_quoted_media.reload.file).to be_present + expect(remote_quoting_media.reload.file).to be_present + + expect(media_attachment.reload.file).to be_blank + expect(media_attachment.reload.thumbnail).to be_blank + end + end end end From d0502ac3c1630e281fda5492cbc28390262b0aeb Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 29 Jan 2026 10:56:10 -0500 Subject: [PATCH 12/12] Use "namespace style" for URL generation in `admin/` area forms (#35080) --- app/views/admin/announcements/edit.html.haml | 2 +- app/views/admin/announcements/new.html.haml | 2 +- app/views/admin/custom_emojis/new.html.haml | 2 +- app/views/admin/domain_allows/new.html.haml | 2 +- app/views/admin/domain_blocks/edit.html.haml | 2 +- app/views/admin/domain_blocks/new.html.haml | 2 +- app/views/admin/email_domain_blocks/new.html.haml | 2 +- app/views/admin/invites/index.html.haml | 2 +- app/views/admin/ip_blocks/new.html.haml | 2 +- app/views/admin/relays/new.html.haml | 2 +- app/views/admin/rules/edit.html.haml | 2 +- app/views/admin/rules/new.html.haml | 2 +- app/views/admin/username_blocks/edit.html.haml | 2 +- app/views/admin/username_blocks/new.html.haml | 2 +- app/views/admin/webhooks/edit.html.haml | 2 +- app/views/admin/webhooks/new.html.haml | 2 +- 16 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/views/admin/announcements/edit.html.haml b/app/views/admin/announcements/edit.html.haml index 8cec7d36c2..5f2022be68 100644 --- a/app/views/admin/announcements/edit.html.haml +++ b/app/views/admin/announcements/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @announcement, url: admin_announcement_path(@announcement), html: { novalidate: false } do |form| += simple_form_for [:admin, @announcement], html: { novalidate: false } do |form| = render 'shared/error_messages', object: @announcement = render form diff --git a/app/views/admin/announcements/new.html.haml b/app/views/admin/announcements/new.html.haml index 266ca65e80..e7adb98f2f 100644 --- a/app/views/admin/announcements/new.html.haml +++ b/app/views/admin/announcements/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @announcement, url: admin_announcements_path, html: { novalidate: false } do |form| += simple_form_for [:admin, @announcement], html: { novalidate: false } do |form| = render 'shared/error_messages', object: @announcement = render form diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/admin/custom_emojis/new.html.haml index e59ae02b3b..acf2231b8e 100644 --- a/app/views/admin/custom_emojis/new.html.haml +++ b/app/views/admin/custom_emojis/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @custom_emoji, url: admin_custom_emojis_path do |f| += simple_form_for [:admin, @custom_emoji] do |f| = render 'shared/error_messages', object: @custom_emoji .fields-group diff --git a/app/views/admin/domain_allows/new.html.haml b/app/views/admin/domain_allows/new.html.haml index 85ab7e4644..d8cefee997 100644 --- a/app/views/admin/domain_allows/new.html.haml +++ b/app/views/admin/domain_allows/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.domain_allows.add_new') -= simple_form_for @domain_allow, url: admin_domain_allows_path do |f| += simple_form_for [:admin, @domain_allow] do |f| = render 'shared/error_messages', object: @domain_allow .fields-group diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml index cd52953a40..b47e0fcdcc 100644 --- a/app/views/admin/domain_blocks/edit.html.haml +++ b/app/views/admin/domain_blocks/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.domain_blocks.edit') -= simple_form_for @domain_block, url: admin_domain_block_path(@domain_block) do |form| += simple_form_for [:admin, @domain_block] do |form| = render 'shared/error_messages', object: @domain_block = render form diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 78bcfcba8e..5f80c9b4f6 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @domain_block, url: admin_domain_blocks_path do |form| += simple_form_for [:admin, @domain_block] do |form| = render 'shared/error_messages', object: @domain_block = render form diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml index 4db8fbe5e5..6508ef1d3b 100644 --- a/app/views/admin/email_domain_blocks/new.html.haml +++ b/app/views/admin/email_domain_blocks/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f| += simple_form_for [:admin, @email_domain_block] do |f| = render 'shared/error_messages', object: @email_domain_block .fields-group diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml index 964deaba8f..1507b816c9 100644 --- a/app/views/admin/invites/index.html.haml +++ b/app/views/admin/invites/index.html.haml @@ -14,7 +14,7 @@ - if policy(:invite).create? %p= t('invites.prompt') - = simple_form_for(@invite, url: admin_invites_path) do |form| + = simple_form_for [:admin, @invite] do |form| = render partial: 'invites/form', object: form %hr.spacer/ diff --git a/app/views/admin/ip_blocks/new.html.haml b/app/views/admin/ip_blocks/new.html.haml index 81493012c6..acf632e476 100644 --- a/app/views/admin/ip_blocks/new.html.haml +++ b/app/views/admin/ip_blocks/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('.title') -= simple_form_for @ip_block, url: admin_ip_blocks_path do |f| += simple_form_for [:admin, @ip_block] do |f| = render 'shared/error_messages', object: @ip_block .fields-group diff --git a/app/views/admin/relays/new.html.haml b/app/views/admin/relays/new.html.haml index 126794acfe..4decb467b8 100644 --- a/app/views/admin/relays/new.html.haml +++ b/app/views/admin/relays/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.relays.add_new') -= simple_form_for @relay, url: admin_relays_path do |f| += simple_form_for [:admin, @relay] do |f| = render 'shared/error_messages', object: @relay .field-group diff --git a/app/views/admin/rules/edit.html.haml b/app/views/admin/rules/edit.html.haml index b64a27d751..944480fa5a 100644 --- a/app/views/admin/rules/edit.html.haml +++ b/app/views/admin/rules/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.rules.edit') -= simple_form_for @rule, url: admin_rule_path(@rule) do |form| += simple_form_for [:admin, @rule] do |form| = render 'shared/error_messages', object: @rule = render form diff --git a/app/views/admin/rules/new.html.haml b/app/views/admin/rules/new.html.haml index bc93c7df55..6c703ef8ff 100644 --- a/app/views/admin/rules/new.html.haml +++ b/app/views/admin/rules/new.html.haml @@ -5,7 +5,7 @@ %hr.spacer/ -= simple_form_for @rule, url: admin_rules_path do |form| += simple_form_for [:admin, @rule] do |form| = render 'shared/error_messages', object: @rule = render form diff --git a/app/views/admin/username_blocks/edit.html.haml b/app/views/admin/username_blocks/edit.html.haml index eee0fedef0..c869bb53df 100644 --- a/app/views/admin/username_blocks/edit.html.haml +++ b/app/views/admin/username_blocks/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.username_blocks.edit.title') -= simple_form_for @username_block, url: admin_username_block_path(@username_block) do |form| += simple_form_for [:admin, @username_block] do |form| = render 'shared/error_messages', object: @username_block = render form diff --git a/app/views/admin/username_blocks/new.html.haml b/app/views/admin/username_blocks/new.html.haml index 0f5bd27952..a63aad5f34 100644 --- a/app/views/admin/username_blocks/new.html.haml +++ b/app/views/admin/username_blocks/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.username_blocks.new.title') -= simple_form_for @username_block, url: admin_username_blocks_path do |form| += simple_form_for [:admin, @username_block] do |form| = render 'shared/error_messages', object: @username_block = render form diff --git a/app/views/admin/webhooks/edit.html.haml b/app/views/admin/webhooks/edit.html.haml index abc9bdfabc..7e7658f726 100644 --- a/app/views/admin/webhooks/edit.html.haml +++ b/app/views/admin/webhooks/edit.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.webhooks.edit') -= simple_form_for @webhook, url: admin_webhook_path(@webhook) do |form| += simple_form_for [:admin, @webhook] do |form| = render form .actions = form.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/webhooks/new.html.haml b/app/views/admin/webhooks/new.html.haml index 50fcdc2be7..be589111db 100644 --- a/app/views/admin/webhooks/new.html.haml +++ b/app/views/admin/webhooks/new.html.haml @@ -1,7 +1,7 @@ - content_for :page_title do = t('admin.webhooks.new') -= simple_form_for @webhook, url: admin_webhooks_path do |form| += simple_form_for [:admin, @webhook] do |form| = render form .actions = form.button :button, t('admin.webhooks.add_new'), type: :submit