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

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

No files matched your search

+3 -3
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}`,
);
@@ -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;
}
@@ -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';
@@ -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,
};
}
@@ -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;
@@ -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;
}
>
>(
(
{
@@ -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,
@@ -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}
@@ -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/`));
@@ -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,
};
}
@@ -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} />
</>
);
@@ -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;
}
@@ -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>
);
};
@@ -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>
@@ -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}
/>
);
};
@@ -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,
@@ -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 }),
@@ -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} />
}
@@ -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');
}
@@ -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,
};
}
+8
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}",
+14
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.",
@@ -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}",
@@ -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}",
+18
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}",
+8
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}",
+28
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}",
@@ -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} 起即将生效的更改",
@@ -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} 起即將發生之異動",
@@ -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;
},
+46 -10
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,
);
},
);
+2
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>;