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/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)', 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 954abfae5e..c1a17b5dc2 100644 --- a/app/javascript/mastodon/api_types/collections.ts +++ b/app/javascript/mastodon/api_types/collections.ts @@ -70,10 +70,10 @@ type CommonPayloadFields = Pick< ApiCollectionJSON, 'name' | 'description' | 'sensitive' | 'discoverable' > & { - tag?: string; + tag_name?: string; }; -export interface ApiPatchCollectionPayload extends Partial { +export interface ApiUpdateCollectionPayload extends Partial { id: string; } 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/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/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() ? ( + + ) : ( + + ))} = ({ 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/features/collections/editor.tsx b/app/javascript/mastodon/features/collections/editor.tsx new file mode 100644 index 0000000000..138e7764e5 --- /dev/null +++ b/app/javascript/mastodon/features/collections/editor.tsx @@ -0,0 +1,272 @@ +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, + ApiUpdateCollectionPayload, +} 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, + fetchCollection, + updateCollection, +} 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) { + const payload: ApiUpdateCollectionPayload = { + id, + name, + description, + tag_name: topic, + discoverable, + sensitive, + }; + + void dispatch(updateCollection({ payload })).then(() => { + history.push(`/collections`); + }); + } 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`); + } + }); + } + }, + [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) { + void dispatch(fetchCollection({ collectionId: 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..bd1c4f790b 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'; @@ -49,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( () => [ @@ -67,7 +67,7 @@ const ListItem: React.FC<{ return (
- + {name} @@ -94,24 +94,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/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/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/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/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/en.json b/app/javascript/mastodon/locales/en.json index 1468848715..49448c4f33 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 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.", "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", @@ -281,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?", @@ -427,6 +440,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/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} 起即將發生之異動", 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; }, 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; 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/models/media_attachment.rb b/app/models/media_attachment.rb index 92485b06ee..0c8fecb198 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 60b8f120cf..e591d553d7 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -92,6 +92,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/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/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 11ea037351..1181f477ea 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 diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 7b9ae5eada..fbde4f115f 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -57,7 +57,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 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/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/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 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 diff --git a/streaming/index.js b/streaming/index.js index 1ce7fd9b60..9fa444de8b 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 f2f934ebff..985ae94fab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3007,7 +3007,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" @@ -5545,13 +5545,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 @@ -6643,13 +6643,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"