Merge commit 'd0502ac3c1630e281fda5492cbc28390262b0aeb' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2026-01-29 21:08:46 +01:00
commit d62a7775b5
69 changed files with 1090 additions and 284 deletions

View File

@ -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)

View File

@ -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)',

View File

@ -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<ApiWrappedCollectionJSON>(
`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<ApiCollectionWithAccountsJSON[]>(
apiRequestGet<ApiCollectionWithAccountsJSON>(
`v1_alpha/collections/${collectionId}`,
);

View File

@ -70,10 +70,10 @@ type CommonPayloadFields = Pick<
ApiCollectionJSON,
'name' | 'description' | 'sensitive' | 'discoverable'
> & {
tag?: string;
tag_name?: string;
};
export interface ApiPatchCollectionPayload extends Partial<CommonPayloadFields> {
export interface ApiUpdateCollectionPayload extends Partial<CommonPayloadFields> {
id: string;
}

View File

@ -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';

View File

@ -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<MiniCardListProps> = ({
</div>
);
};
function useOverflow() {
const [hiddenIndex, setHiddenIndex] = useState(-1);
const [hiddenCount, setHiddenCount] = useState(0);
const [maxWidth, setMaxWidth] = useState<number | 'none'>('none');
// This is the item container element.
const listRef = useRef<HTMLElement | null>(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<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(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<HTMLElement | null>(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,
};
}

View File

@ -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;

View File

@ -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<ComponentPropsWithoutRef<'button'>, 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 && <Icon icon={icon} id='tag-icon' className={classes.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;
}
>
>(
(
{

View File

@ -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<TagsProps> = ({ tags, active, onRemove, ...props }) => {
if (onRemove) {
export const Tags = forwardRef<HTMLDivElement, TagsProps>(
({ tags, active, onRemove, className, ...props }, ref) => {
if (onRemove) {
return (
<div className={classNames(classes.tagsWrapper, className)}>
{tags.map((tag) => (
<MappedTag
key={tag.name}
active={tag.name === active}
onRemove={onRemove}
{...tag}
{...props}
/>
))}
</div>
);
}
return (
<div className={classes.tagsWrapper}>
<div className={classNames(classes.tagsWrapper, className)} ref={ref}>
{tags.map((tag) => (
<MappedTag
<Tag
key={tag.name}
active={tag.name === active}
onRemove={onRemove}
{...tag}
{...props}
/>
))}
</div>
);
}
return (
<div className={classes.tagsWrapper}>
{tags.map((tag) => (
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
))}
</div>
);
};
},
);
Tags.displayName = 'Tags';
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
onRemove,

View File

@ -162,11 +162,13 @@ export const AccountHeader: React.FC<{
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{me && account.id !== me && isRedesignEnabled() ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
)}
{me &&
account.id !== me &&
(isRedesignEnabled() ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
))}
<AccountBio
accountId={accountId}

View File

@ -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 (
<div className={classes.tabs}>
<NavLink exact to={`/@${acct}`}>
<NavLink isActive={isActive} to={`/@${acct}`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</NavLink>
<NavLink exact to={`/@${acct}/media`}>
@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
</div>
);
};
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
match?.url === location.pathname ||
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));

View File

@ -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 (
<div className={classes.tagsWrapper} ref={wrapperRef}>
<div
className={classNames(
classes.tagsList,
showOverflow && classes.tagsListShowAll,
)}
style={{ maxWidth }}
ref={listRef}
>
{featuredTags.map(({ id, name }, index) => (
<Tag
name={name}
key={id}
inert={hiddenIndex > 0 && index >= hiddenIndex ? '' : undefined}
onClick={onClick}
active={currentTag === name}
data-name={name}
/>
))}
</div>
{!showOverflow && hiddenCount > 0 && (
<Tag
onClick={handleOverflowClick}
name={
<FormattedMessage
id='featured_tags.more_items'
defaultMessage='+{count}'
values={{ count: hiddenCount }}
/>
}
/>
)}
</div>
);
};
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<HTMLButtonElement> = 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,
};
}

View File

@ -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<string>();
@ -135,6 +136,7 @@ const Prepend: FC<{
<>
<AccountHeader accountId={accountId} hideTabs />
<AccountFilters />
<FeaturedTags accountId={accountId} />
<FeaturedCarousel accountId={accountId} tagged={tagged} />
</>
);

View File

@ -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;
}

View File

@ -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<HTMLInputElement>) => {
setName(event.target.value);
},
[],
);
const handleDescriptionChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDescription(event.target.value);
},
[],
);
const handleTopicChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTopic(event.target.value);
},
[],
);
const handleSensitiveChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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 (
<form className='simple_form app-form' onSubmit={handleSubmit}>
<div className='fields-group'>
<TextInputField
required
label={
<FormattedMessage
id='collections.collection_name'
defaultMessage='Name'
/>
}
hint={
<FormattedMessage
id='collections.name_length_hint'
defaultMessage='40 characters limit'
/>
}
value={name}
onChange={handleNameChange}
maxLength={40}
/>
</div>
<div className='fields-group'>
<TextAreaField
required
label={
<FormattedMessage
id='collections.collection_description'
defaultMessage='Description'
/>
}
hint={
<FormattedMessage
id='collections.description_length_hint'
defaultMessage='100 characters limit'
/>
}
value={description}
onChange={handleDescriptionChange}
maxLength={100}
/>
</div>
<div className='fields-group'>
<TextInputField
required={false}
label={
<FormattedMessage
id='collections.collection_topic'
defaultMessage='Topic'
/>
}
hint={
<FormattedMessage
id='collections.topic_hint'
defaultMessage='Add a hashtag that helps others understand the main topic of this collection.'
/>
}
value={topic}
onChange={handleTopicChange}
maxLength={40}
/>
</div>
<div className='fields-group'>
<ToggleField
label={
<FormattedMessage
id='collections.mark_as_sensitive'
defaultMessage='Mark as sensitive'
/>
}
hint={
<FormattedMessage
id='collections.mark_as_sensitive_hint'
defaultMessage="Hides the collection's description and accounts behind a content warning. The collection name will still be visible."
/>
}
checked={sensitive}
onChange={handleSensitiveChange}
/>
</div>
<div className='actions'>
<Button type='submit'>
{id ? (
<FormattedMessage id='lists.save' defaultMessage='Save' />
) : (
<FormattedMessage id='lists.create' defaultMessage='Create' />
)}
</Button>
</div>
</form>
);
};
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 (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
title={pageTitle}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
showBackButton
/>
<div className='scrollable'>
{isLoading ? (
<LoadingIndicator />
) : (
<CollectionSettings collection={collection} />
)}
</div>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@ -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 (
<div className='lists__item'>
<Link to={`/collections/${id}`} className='lists__item__title'>
<Link to={`/collections/${id}/edit`} className='lists__item__title'>
<span>{name}</span>
</Link>
@ -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' ? (
<FormattedMessage
@ -152,7 +134,6 @@ export const Collections: React.FC<{
className='column-header__button'
title={intl.formatMessage(messages.create)}
aria-label={intl.formatMessage(messages.create)}
onClick={addDummyCollection}
>
<Icon id='plus' icon={AddIcon} />
</Link>

View File

@ -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 (
<ConfirmationModal
title={intl.formatMessage(messages.deleteListTitle, {
name,
})}
message={intl.formatMessage(messages.deleteListMessage)}
confirm={intl.formatMessage(messages.deleteListConfirm)}
onConfirm={onConfirm}
onClose={onClose}
/>
);
};

View File

@ -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,

View File

@ -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 }),

View File

@ -64,6 +64,7 @@ import {
ListEdit,
ListMembers,
Collections,
CollectionsEditor,
Blocks,
DomainBlocks,
Mutes,
@ -229,6 +230,12 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
<WrappedRoute path='/collections/new' component={CollectionsEditor} content={children} />
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections/:id/edit' component={CollectionsEditor} content={children} />
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}

View File

@ -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');
}

View File

@ -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<number | 'none'>('none');
// This is the item container element.
const listRef = useRef<HTMLElement | null>(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<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(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<HTMLElement | null>(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,
};
}

View File

@ -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}",

View File

@ -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": "<sr>Post</sr> {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.",

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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}",

View File

@ -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": "<sr>Side</sr> {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}",

View File

@ -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} 起即将生效的更改",

View File

@ -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} 起即將發生之異動",

View File

@ -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<string, ApiCollectionJSON> = {};
const collectionsMap: Record<string, ApiCollectionJSON> =
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;
},

View File

@ -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<FullAccountShape>({
});
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<ImmutableMap<string, string | null>>;
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,
);
},
);

View File

@ -22,3 +22,5 @@ export type OmitValueType<T, V> = {
};
export type AnyFunction = (...args: never) => unknown;
export type OmitUnion<TUnion, TBase> = TBase & Omit<TUnion, keyof TBase>;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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/

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -30,7 +30,8 @@ const dotenvFilePath = path.resolve(
);
dotenv.config({
path: dotenvFilePath
path: dotenvFilePath,
quiet: true,
});
initializeLogLevel(process.env, environment);

View File

@ -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",

View File

@ -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"