Merge pull request #3483 from glitch-soc/glitch-soc/merge-upstream
Merge upstream changes up to 3a84990780fb1b48999f661f0cea5cb29cca4d33 into glitch-soc
This commit is contained in:
commit
91f7737f8b
17
CHANGELOG.md
17
CHANGELOG.md
@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [4.5.9] - 2026-04-15
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Insufficient verification of email addresses ([GHSA-5r37-qpwq-2jhh](https://github.com/mastodon/mastodon/security/advisories/GHSA-5r37-qpwq-2jhh))
|
||||||
|
- Updated dependencies
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add trademark warning to `mastodon:setup` task (#38548 by @ClearlyClaire)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix definition for `quote` in JSON-LD context (#38686 by @ClearlyClaire)
|
||||||
|
- Fix being unable to disable sound for quote update notification (#38537 by @ClearlyClaire)
|
||||||
|
- Fix being able to quote someone you blocked (#38608 by @ClearlyClaire)
|
||||||
|
|
||||||
## [4.5.8] - 2026-03-24
|
## [4.5.8] - 2026-03-24
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@ -20,7 +20,7 @@ Mastodon Glitch Edition is a fork of [Mastodon](https://github.com/mastodon/mast
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a style="text-decoration:none" href="https://www.youtube.com/watch?v=IPSbNdBmWKE">
|
<a style="text-decoration:none" href="https://www.youtube.com/watch?v=IPSbNdBmWKE">
|
||||||
<img alt="Mastodon hero image" src="https://github.com/user-attachments/assets/ef53f5e9-c0d8-484d-9f53-00efdebb92c3" />
|
<img alt="Mastodon hero image" src="./docs/hero-nodes.gif" />
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@ -278,7 +278,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
if (account?.mute_expires_at) {
|
if (account?.mute_expires_at) {
|
||||||
muteTimeRemaining = (
|
muteTimeRemaining = (
|
||||||
<>
|
<>
|
||||||
· <RelativeTimestamp timestamp={account.mute_expires_at} />
|
· <RelativeTimestamp hasFuture timestamp={account.mute_expires_at} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,11 +149,7 @@ export const AccountListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{account.last_status_at ? (
|
{account.last_status_at ? (
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp long timestamp={account.last_status_at} />
|
||||||
long
|
|
||||||
timestamp={account.last_status_at}
|
|
||||||
noFuture
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -52,8 +52,10 @@ export const Badge: FC<BadgeProps> = ({
|
|||||||
data-account-role-id={roleId}
|
data-account-role-id={roleId}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span className={classes.content}>
|
||||||
{domain && <span className={classes.domain}>{domain}</span>}
|
{label}
|
||||||
|
{domain && <span className={classes.domain}> {domain}</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
|
||||||
> svg {
|
> svg {
|
||||||
|
flex-shrink: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
@ -26,6 +26,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.domain {
|
.domain {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
|||||||
if (expired) {
|
if (expired) {
|
||||||
return intl.formatMessage(messages.closed);
|
return intl.formatMessage(messages.closed);
|
||||||
}
|
}
|
||||||
return <RelativeTimestamp timestamp={poll.expires_at} />;
|
return <RelativeTimestamp hasFuture timestamp={poll.expires_at} />;
|
||||||
}, [expired, intl, poll]);
|
}, [expired, intl, poll]);
|
||||||
const votesCount = useMemo(() => {
|
const votesCount = useMemo(() => {
|
||||||
if (!poll) {
|
if (!poll) {
|
||||||
|
|||||||
@ -23,16 +23,16 @@ export const RelativeTimestamp: FC<{
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
long?: boolean;
|
long?: boolean;
|
||||||
noTime?: boolean;
|
noTime?: boolean;
|
||||||
noFuture?: boolean;
|
hasFuture?: boolean;
|
||||||
}> = ({ timestamp, long = false, noTime = false, noFuture = false }) => {
|
}> = ({ timestamp, long = false, noTime = false, hasFuture = false }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const date = useMemo(() => {
|
const date = useMemo(() => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return noFuture ? new Date(Math.min(date.getTime(), now)) : date;
|
return !hasFuture ? new Date(Math.min(date.getTime(), now)) : date;
|
||||||
}, [noFuture, now, timestamp]);
|
}, [hasFuture, now, timestamp]);
|
||||||
const ts = date.getTime();
|
const ts = date.getTime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const meta = {
|
|||||||
timestamp: new Date(Date.now() - DAY * 3).toISOString(),
|
timestamp: new Date(Date.now() - DAY * 3).toISOString(),
|
||||||
long: false,
|
long: false,
|
||||||
noTime: false,
|
noTime: false,
|
||||||
noFuture: false,
|
hasFuture: false,
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
timestamp: {
|
timestamp: {
|
||||||
@ -44,10 +44,10 @@ export const DateOnly: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoFuture: Story = {
|
export const HasFuture: Story = {
|
||||||
args: {
|
args: {
|
||||||
timestamp: new Date(Date.now() + DAY * 3).toISOString(),
|
timestamp: new Date(Date.now() + DAY * 3).toISOString(),
|
||||||
noFuture: true,
|
hasFuture: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -400,7 +400,6 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp
|
||||||
timestamp={status.get('created_at')}
|
timestamp={status.get('created_at')}
|
||||||
noFuture
|
|
||||||
/>{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
/>{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { Article } from '@/flavours/glitch/components/scrollable_list/components';
|
||||||
|
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||||
|
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
||||||
|
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
import type { IconProp } from '../icon';
|
||||||
|
import { ListItemButton, ListItemWrapper } from '../list_item';
|
||||||
|
|
||||||
|
export interface TruncatedListItemInfo<TListItem> {
|
||||||
|
item: TListItem;
|
||||||
|
index: number;
|
||||||
|
totalListLength: number;
|
||||||
|
isLastElement: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleButtonOptions {
|
||||||
|
title: NonNullable<React.ReactNode>;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
icon?: IconProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TruncatedListProps<TListItem> {
|
||||||
|
visibleItems: TListItem[];
|
||||||
|
truncatedItems: TListItem[];
|
||||||
|
renderListItem: (
|
||||||
|
itemInfo: TruncatedListItemInfo<TListItem>,
|
||||||
|
) => React.ReactElement;
|
||||||
|
toggleButton: ToggleButtonOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate the children of an `ItemList` component with this helper
|
||||||
|
* component.
|
||||||
|
* It handles rendering the children with correct indexes for accessibility,
|
||||||
|
* and has a configurable toggle button.
|
||||||
|
*/
|
||||||
|
export const TruncatedListItems = <TListItem,>({
|
||||||
|
visibleItems,
|
||||||
|
truncatedItems,
|
||||||
|
toggleButton,
|
||||||
|
renderListItem,
|
||||||
|
}: TruncatedListProps<TListItem>) => {
|
||||||
|
const [showTruncatedItems, setShowTruncatedItems] = useState(false);
|
||||||
|
const toggleTruncatedItems = useCallback(() => {
|
||||||
|
setShowTruncatedItems((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasHiddenAccounts = truncatedItems.length > 0;
|
||||||
|
// Add the toggle button's item to the list size when needed
|
||||||
|
const initialListSize = visibleItems.length + (hasHiddenAccounts ? 1 : 0);
|
||||||
|
const totalListLength =
|
||||||
|
initialListSize + (showTruncatedItems ? truncatedItems.length : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleItems.map((item, index) => {
|
||||||
|
return renderListItem({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement:
|
||||||
|
index === visibleItems.length - 1 && !hasHiddenAccounts,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
{hasHiddenAccounts && (
|
||||||
|
<Article aria-posinset={initialListSize} aria-setsize={totalListLength}>
|
||||||
|
<ListItemWrapper
|
||||||
|
icon={
|
||||||
|
toggleButton.icon && (
|
||||||
|
<Icon id='toggle-icon' icon={toggleButton.icon} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconEnd={
|
||||||
|
<Icon
|
||||||
|
id='open-status'
|
||||||
|
icon={
|
||||||
|
showTruncatedItems
|
||||||
|
? KeyboardArrowUpIcon
|
||||||
|
: KeyboardArrowDownIcon
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
aria-expanded={showTruncatedItems}
|
||||||
|
onClick={toggleTruncatedItems}
|
||||||
|
subtitle={toggleButton.subtitle}
|
||||||
|
>
|
||||||
|
{toggleButton.title}
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItemWrapper>
|
||||||
|
</Article>
|
||||||
|
)}
|
||||||
|
{showTruncatedItems &&
|
||||||
|
truncatedItems.map((item, index) => {
|
||||||
|
return renderListItem({
|
||||||
|
item,
|
||||||
|
index: initialListSize + index + 1,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement: index === truncatedItems.length - 1,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
[data-color-scheme='dark'] .image {
|
||||||
|
--color-skin-1: #3a3a50;
|
||||||
|
--color-skin-2: #67678e;
|
||||||
|
--color-skin-3: #44445f;
|
||||||
|
--color-outline: #21212c;
|
||||||
|
--color-shadow: #181820;
|
||||||
|
--color-highlight: #b2b1c8;
|
||||||
|
}
|
||||||
@ -11,10 +11,10 @@ import { EmptyState } from '@/flavours/glitch/components/empty_state';
|
|||||||
import { LimitedAccountHint } from '@/flavours/glitch/features/account_timeline/components/limited_account_hint';
|
import { LimitedAccountHint } from '@/flavours/glitch/features/account_timeline/components/limited_account_hint';
|
||||||
import { areCollectionsEnabled } from '@/flavours/glitch/features/collections/utils';
|
import { areCollectionsEnabled } from '@/flavours/glitch/features/collections/utils';
|
||||||
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
import { useTheme } from '@/flavours/glitch/hooks/useTheme';
|
|
||||||
import { useAppDispatch } from '@/flavours/glitch/store';
|
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||||
import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react';
|
import ElephantImage from '@/images/elephant_ui.svg?react';
|
||||||
import ElephantLightImage from '@/images/elephant_ui_light.svg?react';
|
|
||||||
|
import classes from './empty_message.module.scss';
|
||||||
|
|
||||||
interface EmptyMessageProps {
|
interface EmptyMessageProps {
|
||||||
suspended: boolean;
|
suspended: boolean;
|
||||||
@ -33,9 +33,6 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { acct } = useParams<{ acct?: string }>();
|
const { acct } = useParams<{ acct?: string }>();
|
||||||
const me = useCurrentAccountId();
|
const me = useCurrentAccountId();
|
||||||
const theme = useTheme();
|
|
||||||
const ElephantImage =
|
|
||||||
theme === 'dark' ? ElephantDarkImage : ElephantLightImage;
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@ -57,7 +54,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
|||||||
|
|
||||||
const hasCollections = areCollectionsEnabled();
|
const hasCollections = areCollectionsEnabled();
|
||||||
|
|
||||||
const image = <ElephantImage />;
|
const image = <ElephantImage className={classes.image} />;
|
||||||
|
|
||||||
if (me === accountId) {
|
if (me === accountId) {
|
||||||
if (hasCollections) {
|
if (hasCollections) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
@ -6,10 +6,9 @@ import { useHistory } from 'react-router';
|
|||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import { AccountListItem } from '@/flavours/glitch/components/account_list_item';
|
|
||||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts';
|
import { fetchEndorsedAccounts } from 'flavours/glitch/actions/accounts';
|
||||||
|
import { AccountListItem } from 'flavours/glitch/components/account_list_item';
|
||||||
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
import { ColumnBackButton } from 'flavours/glitch/components/column_back_button';
|
||||||
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
import { LoadingIndicator } from 'flavours/glitch/components/loading_indicator';
|
||||||
import { RemoteHint } from 'flavours/glitch/components/remote_hint';
|
import { RemoteHint } from 'flavours/glitch/components/remote_hint';
|
||||||
@ -18,9 +17,12 @@ import {
|
|||||||
ItemList,
|
ItemList,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
} from 'flavours/glitch/components/scrollable_list/components';
|
} from 'flavours/glitch/components/scrollable_list/components';
|
||||||
|
import type { TruncatedListItemInfo } from 'flavours/glitch/components/truncated_list';
|
||||||
|
import { TruncatedListItems } from 'flavours/glitch/components/truncated_list';
|
||||||
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
|
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
|
||||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
import Column from 'flavours/glitch/features/ui/components/column';
|
import Column from 'flavours/glitch/features/ui/components/column';
|
||||||
|
import { useAccount } from 'flavours/glitch/hooks/useAccount';
|
||||||
import { useAccountId } from 'flavours/glitch/hooks/useAccountId';
|
import { useAccountId } from 'flavours/glitch/hooks/useAccountId';
|
||||||
import { useAccountVisibility } from 'flavours/glitch/hooks/useAccountVisibility';
|
import { useAccountVisibility } from 'flavours/glitch/hooks/useAccountVisibility';
|
||||||
import {
|
import {
|
||||||
@ -74,11 +76,32 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||||||
const { collections, status: collectionsLoadStatus } = useAppSelector(
|
const { collections, status: collectionsLoadStatus } = useAppSelector(
|
||||||
(state) => selectAccountCollections(state, accountId ?? null),
|
(state) => selectAccountCollections(state, accountId ?? null),
|
||||||
);
|
);
|
||||||
const listedCollections = collections.filter(
|
|
||||||
// Hide unlisted and empty collections to avoid confusion
|
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
|
||||||
// (Unlisted collections will only be part of the payload
|
collections,
|
||||||
// when viewing your own profile.)
|
(item) =>
|
||||||
(item) => item.discoverable && !!item.item_count,
|
item.discoverable && !!item.item_count
|
||||||
|
? 'listedCollections'
|
||||||
|
: 'unlistedCollections',
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderListItem = useCallback(
|
||||||
|
({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement,
|
||||||
|
}: TruncatedListItemInfo<(typeof listedCollections)[number]>) => (
|
||||||
|
<CollectionListItem
|
||||||
|
key={item.id}
|
||||||
|
collection={item}
|
||||||
|
withoutBorder={isLastElement}
|
||||||
|
withAuthorHandle={false}
|
||||||
|
positionInList={index}
|
||||||
|
listSize={totalListLength}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCollections =
|
const hasCollections =
|
||||||
@ -167,16 +190,26 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||||||
</Subheading>
|
</Subheading>
|
||||||
{hasCollections ? (
|
{hasCollections ? (
|
||||||
<ItemList>
|
<ItemList>
|
||||||
{listedCollections.map((item, index) => (
|
<TruncatedListItems
|
||||||
<CollectionListItem
|
visibleItems={listedCollections}
|
||||||
key={item.id}
|
truncatedItems={unlistedCollections}
|
||||||
collection={item}
|
toggleButton={{
|
||||||
withoutBorder={index === listedCollections.length - 1}
|
title: (
|
||||||
withAuthorHandle={false}
|
<FormattedMessage
|
||||||
positionInList={index + 1}
|
id='collections.unlisted_collections_with_count'
|
||||||
listSize={listedCollections.length}
|
defaultMessage='Unlisted collections ({count})'
|
||||||
/>
|
values={{ count: unlistedCollections.length }}
|
||||||
))}
|
/>
|
||||||
|
),
|
||||||
|
subtitle: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.unlisted_collections_description'
|
||||||
|
defaultMessage='These don’t appear on your profile to others. Anyone with the link can discover them.'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
renderListItem={renderListItem}
|
||||||
|
/>
|
||||||
</ItemList>
|
</ItemList>
|
||||||
) : (
|
) : (
|
||||||
<EmptyMessage
|
<EmptyMessage
|
||||||
|
|||||||
@ -36,10 +36,7 @@ export const AccountStatusHeader: FC<StatusHeaderProps> = ({
|
|||||||
className='status__relative-time'
|
className='status__relative-time'
|
||||||
>
|
>
|
||||||
<StatusVisibility visibility={status.get('visibility')} />
|
<StatusVisibility visibility={status.get('visibility')} />
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp timestamp={status.get('created_at') as string} />
|
||||||
timestamp={status.get('created_at') as string}
|
|
||||||
noFuture
|
|
||||||
/>
|
|
||||||
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import {
|
|
||||||
ListItemButton,
|
|
||||||
ListItemWrapper,
|
|
||||||
} from '@/flavours/glitch/components/list_item';
|
|
||||||
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
|
||||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
|
||||||
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
|
||||||
import type { RenderButtonOptions } from 'flavours/glitch/components/account_list_item';
|
import type { RenderButtonOptions } from 'flavours/glitch/components/account_list_item';
|
||||||
@ -18,13 +11,15 @@ import {
|
|||||||
} from 'flavours/glitch/components/account_list_item';
|
} from 'flavours/glitch/components/account_list_item';
|
||||||
import { Button } from 'flavours/glitch/components/button';
|
import { Button } from 'flavours/glitch/components/button';
|
||||||
import { Callout } from 'flavours/glitch/components/callout';
|
import { Callout } from 'flavours/glitch/components/callout';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
ItemList,
|
ItemList,
|
||||||
} from 'flavours/glitch/components/scrollable_list/components';
|
} from 'flavours/glitch/components/scrollable_list/components';
|
||||||
|
import type { TruncatedListItemInfo } from 'flavours/glitch/components/truncated_list';
|
||||||
|
import { TruncatedListItems } from 'flavours/glitch/components/truncated_list';
|
||||||
import { me } from 'flavours/glitch/initial_state';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
import type { Account } from 'flavours/glitch/models/account';
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
import { createAppSelector, useAppSelector } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
|
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
@ -99,13 +94,9 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const confirmRevoke = useConfirmRevoke(collection);
|
const confirmRevoke = useConfirmRevoke(collection);
|
||||||
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||||
const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false);
|
|
||||||
const toggleHiddenAccounts = useCallback(() => {
|
|
||||||
setCanShowHiddenAccounts((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isOwnCollection = collection?.account_id === me;
|
const isOwnCollection = collection?.account_id === me;
|
||||||
const { items = [], account_id: collectionOwnerId, id } = collection ?? {};
|
const { account_id: collectionOwnerId, id } = collection ?? {};
|
||||||
|
|
||||||
const relationships = useAppSelector((state) => state.relationships);
|
const relationships = useAppSelector((state) => state.relationships);
|
||||||
const collectionAccounts = useAppSelector((state) =>
|
const collectionAccounts = useAppSelector((state) =>
|
||||||
@ -135,9 +126,6 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
return { visibleAccounts, hiddenAccounts };
|
return { visibleAccounts, hiddenAccounts };
|
||||||
}, [collectionAccounts, relationships]);
|
}, [collectionAccounts, relationships]);
|
||||||
|
|
||||||
const hasHiddenAccounts = hiddenAccounts.length > 0;
|
|
||||||
const initialListSize = visibleAccounts.length + (hasHiddenAccounts ? 1 : 0);
|
|
||||||
|
|
||||||
const renderAccountItemButton = useCallback(
|
const renderAccountItemButton = useCallback(
|
||||||
({ relationship, accountId }: RenderButtonOptions) => {
|
({ relationship, accountId }: RenderButtonOptions) => {
|
||||||
// When viewing your own collection, only show the Follow button
|
// When viewing your own collection, only show the Follow button
|
||||||
@ -165,6 +153,28 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
[collectionOwnerId, confirmRevoke],
|
[collectionOwnerId, confirmRevoke],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderListItem = useCallback(
|
||||||
|
({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement,
|
||||||
|
}: TruncatedListItemInfo<Account>) => (
|
||||||
|
<Article
|
||||||
|
key={item.id}
|
||||||
|
aria-posinset={index + 1}
|
||||||
|
aria-setsize={totalListLength}
|
||||||
|
>
|
||||||
|
<AccountListItem
|
||||||
|
accountId={item.id}
|
||||||
|
withBorder={!isLastElement}
|
||||||
|
renderButton={renderAccountItemButton}
|
||||||
|
/>
|
||||||
|
</Article>
|
||||||
|
),
|
||||||
|
[renderAccountItemButton],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3
|
<h3
|
||||||
@ -194,71 +204,28 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage={intl.formatMessage(messages.empty)}
|
emptyMessage={intl.formatMessage(messages.empty)}
|
||||||
>
|
>
|
||||||
{visibleAccounts.map(({ id }, index) => (
|
<TruncatedListItems
|
||||||
<Article
|
visibleItems={visibleAccounts}
|
||||||
key={id}
|
truncatedItems={hiddenAccounts}
|
||||||
aria-posinset={index + 1}
|
toggleButton={{
|
||||||
aria-setsize={initialListSize}
|
icon: VisibilityOffIcon,
|
||||||
>
|
title: (
|
||||||
<AccountListItem
|
<FormattedMessage
|
||||||
accountId={id}
|
id='collections.hidden_accounts_link'
|
||||||
withBorder={index !== items.length - 1 || hasHiddenAccounts}
|
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||||
renderButton={renderAccountItemButton}
|
values={{ count: hiddenAccounts.length }}
|
||||||
/>
|
|
||||||
</Article>
|
|
||||||
))}
|
|
||||||
{hasHiddenAccounts && (
|
|
||||||
<Article
|
|
||||||
aria-posinset={initialListSize}
|
|
||||||
aria-setsize={initialListSize}
|
|
||||||
>
|
|
||||||
<ListItemWrapper
|
|
||||||
icon={<Icon id='visibility-off' icon={VisibilityOffIcon} />}
|
|
||||||
iconEnd={
|
|
||||||
<Icon
|
|
||||||
id='open-status'
|
|
||||||
icon={
|
|
||||||
canShowHiddenAccounts
|
|
||||||
? KeyboardArrowUpIcon
|
|
||||||
: KeyboardArrowDownIcon
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemButton
|
|
||||||
aria-expanded={canShowHiddenAccounts}
|
|
||||||
onClick={toggleHiddenAccounts}
|
|
||||||
subtitle={
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.hidden_accounts_description'
|
|
||||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
|
||||||
values={{ count: hiddenAccounts.length }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.hidden_accounts_link'
|
|
||||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
|
||||||
values={{ count: hiddenAccounts.length }}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItemWrapper>
|
|
||||||
</Article>
|
|
||||||
)}
|
|
||||||
{canShowHiddenAccounts &&
|
|
||||||
hiddenAccounts.map(({ id }, index) => (
|
|
||||||
<Article
|
|
||||||
key={id}
|
|
||||||
aria-posinset={initialListSize + index + 1}
|
|
||||||
aria-setsize={initialListSize + hiddenAccounts.length}
|
|
||||||
>
|
|
||||||
<AccountListItem
|
|
||||||
accountId={id}
|
|
||||||
withBorder={index !== hiddenAccounts.length - 1}
|
|
||||||
renderButton={renderAccountItemButton}
|
|
||||||
/>
|
/>
|
||||||
</Article>
|
),
|
||||||
))}
|
subtitle: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.hidden_accounts_description'
|
||||||
|
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||||
|
values={{ count: hiddenAccounts.length }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
renderListItem={renderListItem}
|
||||||
|
/>
|
||||||
</ItemList>
|
</ItemList>
|
||||||
</SensitiveScreen>
|
</SensitiveScreen>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10998,14 +10998,7 @@ noscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__source {
|
&__source {
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: help;
|
cursor: help;
|
||||||
|
|
||||||
> span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
app/javascript/images/elephant_ui.svg
Normal file
95
app/javascript/images/elephant_ui.svg
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 334 353">
|
||||||
|
<ellipse cx="162.778" cy="327.222" fill="var(--color-shadow, #f2f2f7)" rx="113.889" ry="25" />
|
||||||
|
<g clip-path="url(#a)">
|
||||||
|
<path fill="var(--color-skin-1, #d7d6e1)"
|
||||||
|
d="M181.219 292.602s.546 21.402 1.091 27.288 1.636 8.561 10.909 9.096 22.365-1.071 27.274-2.141 8.727-3.21 7.636-15.516-2.182-27.288-2.182-27.288" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M181.219 292.602s.546 21.402 1.091 27.288 1.636 8.561 10.909 9.096 22.365-1.071 27.274-2.141 8.727-3.21 7.636-15.516-2.182-27.288-2.182-27.288" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)" stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-miterlimit="10" stroke-width="1.826"
|
||||||
|
d="m227.584 320.851-.054-.321c-.818-3.05-2.455-3.746-4.364-3.371-2.618.535-3.327 4.12-3.764 8.935 0 .214-.054.428-.054.696l.382.16c.218-.053.49-.107.709-.16 3.218-.642 5.945-1.819 7.145-5.939" />
|
||||||
|
<path fill="var(--color-skin-3, #eeedf3)"
|
||||||
|
d="M231.184 121.917c25.092-2.676 43.092-14.982 45.82-17.122 0 0-7.637-6.42-1.637-10.701 6-4.28 9.819 1.07 9.819 1.07s12.818-15.999 23.728-7.438c10.909 8.561 15 17.604 11.182 26.165-3.819 8.561-28.365 38.524-80.184 45.48" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M231.184 121.917c25.092-2.676 43.092-14.982 45.82-17.122 0 0-7.637-6.42-1.637-10.701 6-4.28 9.819 1.07 9.819 1.07s12.818-15.999 23.728-7.438c10.909 8.561 15 17.604 11.182 26.165-3.819 8.561-28.365 38.524-80.184 45.48" />
|
||||||
|
<path fill="var(--color-skin-1, #d7d6e1)"
|
||||||
|
d="M83.363 281.364c-8.728 8.026-22.364 12.307-29.455 2.676-7.092-9.632-2.182-13.912-3.819-16.052-1.636-2.14-11.454 4.28-7.09 16.587 4.363 12.306 29.455 25.682 52.91 7.49" />
|
||||||
|
<path fill="var(--color-skin-3, #eeedf3)"
|
||||||
|
d="M225.403 101.638s18.873 37.935 23.51 93.849c3.927 47.62 8.182 74.373-25.092 89.89s-77.456 12.841-98.184 13.376-42.546-2.14-48.001-25.148.273-40.932.273-40.932.763-38.096-1.146-44.249c-1.91-6.153-5.018-15.356-5.018-15.356s-3.11 21.456-19.364 28.679c-16.255 7.277-30.601.428-29.401-19.53 0 0-13.91-22.793-20.51-42.751s10.8-39.54 42.71-31.461c1.091-37.99 34.692-69.932 81.33-73.678 46.583-3.799 57.983 20.707 82.856 21.777 24.001 1.07 40.91-6.42 53.456-18.727 12.546-12.306 38.183-28.358 58.365-15.517 0 0 9.818-3.21 10.909 5.351s-3.818 8.56-6.545 10.166c0 0 3.818 11.771-3.819 13.377-7.636 1.605-6.545-9.631-6.545-9.631s-12.546-1.606-22.91 14.446-25.746 37.561-66.874 46.069c-8.782.749-16.091-2.087-16.091-2.087z" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M225.403 101.638s18.873 37.935 23.51 93.849c3.927 47.62 8.182 74.373-25.092 89.89s-77.456 12.841-98.184 13.376-42.546-2.14-48.001-25.148.273-40.932.273-40.932.763-38.096-1.146-44.249c-1.91-6.153-5.018-15.356-5.018-15.356s-3.11 21.456-19.364 28.679c-16.255 7.277-30.601.428-29.401-19.53 0 0-13.91-22.793-20.51-42.751s10.8-39.54 42.71-31.461c1.091-37.99 34.692-69.932 81.33-73.678 46.583-3.799 57.983 20.707 82.856 21.777 24.001 1.07 40.91-6.42 53.456-18.727 12.546-12.306 38.183-28.358 58.365-15.517 0 0 9.818-3.21 10.909 5.351s-3.818 8.56-6.545 10.166c0 0 3.818 11.771-3.819 13.377-7.636 1.605-6.545-9.631-6.545-9.631s-12.546-1.606-22.91 14.446-25.746 37.561-66.874 46.069m0 0c-8.782.749-16.091-2.087-16.091-2.087" />
|
||||||
|
<path fill="var(--color-skin-1, #d7d6e1)"
|
||||||
|
d="M94.272 285.11c0 12.306-2.182 28.358-2.727 34.244-.546 5.885 1.636 11.985 11.454 12.306 15.273.535 44.729 2.675 47.456-6.421 3.436-11.557 2.727-31.033 0-37.454" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M94.272 285.11c0 12.306-2.182 28.358-2.727 34.244-.546 5.885 1.636 11.985 11.454 12.306 15.273.535 44.729 2.675 47.456-6.421 3.436-11.557 2.727-31.033 0-37.454" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)" d="M113.471 332.035c.437-5.19.982-8.401 3.71-8.936s4.8 1.659 4.854 9.15" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826" d="M113.471 332.035c.437-5.19.982-8.401 3.71-8.936s4.8 1.659 4.854 9.15" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)" d="M122.199 332.035c.437-5.19.982-8.401 3.709-8.936s4.801 1.659 4.855 9.15" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M122.199 332.035c.437-5.19.982-8.401 3.709-8.936s4.801 1.659 4.855 9.15" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)"
|
||||||
|
d="M130.818 332.195c.437-5.19 1.091-9.096 3.818-9.631s4.855 1.124 4.91 8.561" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M130.818 332.195c.437-5.19 1.091-9.096 3.818-9.631s4.855 1.124 4.91 8.561" />
|
||||||
|
<path fill="var(--color-skin-1, #d7d6e1)"
|
||||||
|
d="M21.835 140.216c3.982 4.869 11.237 20.439 10.8 32.639-.49 12.252 12.982 17.549 20.073 6.955s9.055-31.247 4.146-41.306c-4.91-10.113-7.582-13.912-7.582-13.912s-2.673 4.816-9.546 5.779-7.09-1.659-9.436-5.244c0 0 .327 3.96-5.782 6.153-6.11 2.248-8.073-2.675-8.073-2.675s1.473 6.795 5.4 11.611m99.438 157.521c-11.619 0-20.074-1.444-26.674-4.494-8.4-3.906-13.636-10.38-15.873-19.904-5.345-22.473.218-40.183.273-40.344l.055-.16v-.161s.054-1.979.054-5.19c7.582.268 18.164 1.498 27.437 5.779 9.164 4.227 14.946 10.487 17.237 18.513 5.073 17.871 18.273 26.539 40.419 26.539 6.873 0 14.728-.803 23.946-2.461 4.691-.857 9.327-1.499 13.8-2.141 21.11-2.996 39.71-5.618 47.947-25.629-1.8 15.463-8.073 27.662-26.51 36.277-27.982 13.055-63.383 13.109-86.784 13.162-4.2 0-7.8 0-10.964.107-1.582.054-3 .107-4.363.107" />
|
||||||
|
<path fill="var(--color-outline, #b2b1c8)" stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
stroke-miterlimit="10" stroke-width="1.826"
|
||||||
|
d="M180.456 116.78c.709-4.548 1.091-7.865-4.255-6.741-5.345 1.123-38.073 9.631-43.91 10.861-5.891 1.231-4.745 6.475.982 9.631s21.655 12.414 28.201 11.558 12.382-2.034 15.054-10.273c2.673-8.187 3.928-15.036 3.928-15.036" />
|
||||||
|
<path fill="var(--color-skin-1, #d7d6e1)"
|
||||||
|
d="M160.6 141.072c-6.218 0-19.8-7.597-25.582-10.808l-1.2-.695c-2.782-1.552-4.582-3.799-4.309-5.458.163-1.07 1.254-1.819 3-2.194 3-.642 13.091-3.157 22.8-5.618l2.073-.535c8.673 2.247 15.491 7.973 18.219 15.356l-.11.375c-2.454 7.491-7.418 8.668-14.127 9.524-.273.053-.491.053-.764.053" />
|
||||||
|
<path fill="var(--color-highlight, #fff)"
|
||||||
|
d="M130.817 128.925c-1.963-1.391-3.054-3.371-2.727-5.083.273-1.498 1.473-2.515 3.436-2.889 3.164-.643 13.692-3.318 23.837-5.886 4.855-1.231 9.546-2.408 13.255-3.317l1.855 8.025z" />
|
||||||
|
<path fill="var(--color-outline, #b2b1c8)"
|
||||||
|
d="m167.855 113.087 1.364 5.939-38.183 8.775c-1.363-1.123-2.127-2.514-1.909-3.692.273-1.391 1.746-1.872 2.618-2.086 3.164-.642 13.692-3.318 23.892-5.886 4.473-1.124 8.727-2.194 12.218-3.05m1.637-2.622c-11.128 2.729-33.383 8.454-38.183 9.471-5.618 1.177-5.782 6.956-.709 10.166l41.237-9.524z" />
|
||||||
|
<path fill="var(--color-skin-3, #eeedf3)"
|
||||||
|
d="M70.163 79.22c-11.292 1.819-20.128-.16-23.837-14.66-3.71-14.501 9.273-31.516 27.928-38.15s37.091-1.231 37.091-1.231 5.182-12.039 6.982-18.995 7.255-7.544 12.873 2.783c0 0 15.71-2.194 17.619 14.34 1.964 16.532-11.291 24.826-11.291 24.826" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M70.163 79.22c-11.292 1.819-20.128-.16-23.837-14.66-3.71-14.501 9.273-31.516 27.928-38.15s37.091-1.231 37.091-1.231 5.182-12.039 6.982-18.995 7.255-7.544 12.873 2.783c0 0 15.71-2.194 17.619 14.34 1.964 16.532-11.291 24.826-11.291 24.826" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)" d="M131.255 8.912c2.345 5.511 1.582 12.253 1.473 14.447z" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826" d="M131.255 8.912c2.345 5.511 1.582 12.253 1.473 14.447" />
|
||||||
|
<path fill="var(--color-outline, #b2b1c8)"
|
||||||
|
d="M106.055 118.172c1.2 3.049 3.382 5.297 4.854 4.441 1.473-.857 1.582-3.05.219-6.635-1.309-3.532-7.419-15.945-9.055-18.353-1.637-2.407-3.491-3.103-4.964-2.247s-1.036 2.943-.054 4.923c1.036 2.033 9 17.871 9 17.871" />
|
||||||
|
<path fill="var(--color-highlight, #fff)"
|
||||||
|
d="M121.709 168.307c-2.345 0-4.8-.267-7.2-.856-11.564-2.782-17.946-11.504-19.91-18.192-1.254-4.28-.872-7.972 1.091-9.898 1.746-1.766 3.655-2.676 5.564-2.676 4.091 0 7.855 3.853 9.709 6.153 4.146 5.083 8.073 7.652 11.673 7.652 2.891 0 5.4-1.605 7.473-4.816 5.455-8.24 6.928-18.245 4.473-29.642-.818-3.799.164-5.993 1.146-7.17a5.1 5.1 0 0 1 3.981-1.926q1.31 0 2.291.642c9.928 6.314 11.019 27.823 7.419 39.808-3.055 10.06-13.037 20.921-27.71 20.921" />
|
||||||
|
<path fill="var(--color-outline, #b2b1c8)"
|
||||||
|
d="M139.654 108.005c.655 0 1.2.16 1.691.481 4.146 2.622 7.091 8.561 8.292 16.694 1.09 7.277.545 15.677-1.364 21.938-1.582 5.297-4.964 10.273-9.437 13.911-4.963 4.066-10.909 6.207-17.182 6.207a28.3 28.3 0 0 1-6.927-.856c-11.128-2.676-17.237-11.076-19.092-17.443-1.145-3.853-.818-7.224.819-8.829 1.581-1.551 3.163-2.354 4.8-2.354 3.763 0 7.472 4.013 8.891 5.725 4.363 5.351 8.564 8.026 12.491 8.026 3.273 0 6.109-1.766 8.4-5.297 5.618-8.508 7.146-18.727 4.582-30.445-.6-2.622-.273-4.869.927-6.26.818-.963 1.964-1.498 3.109-1.498m0-2.141c-3.654 0-7.691 3.425-6.163 10.381 1.963 8.882 1.963 19.262-4.31 28.786-2.072 3.157-4.363 4.334-6.6 4.334-4.472 0-8.782-4.709-10.8-7.224-2.073-2.568-6.055-6.527-10.582-6.527-2.073 0-4.2.802-6.382 2.996-6 6.046 1.091 25.469 19.364 29.856 2.564.642 5.073.91 7.473.91 14.564 0 25.419-10.594 28.746-21.67 3.873-12.841 2.509-34.458-7.855-41.039-.818-.535-1.854-.803-2.891-.803" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M110.8 152.737c3.545.909 10.582 1.07 16.255-3.532m-79.965-22.42c-2.018 2.675-5.51 4.227-8.891 3.853-3.382-.321-6.491-2.515-7.91-5.565-.6 2.889-2.89 5.458-5.781 6.153-2.891.696-6.219-.374-8.073-2.675.873 6.956 6.545 12.146 9 18.727" />
|
||||||
|
<path fill="var(--color-skin-1, #d7d6e1)"
|
||||||
|
d="m96.181 132.778-9.327 3.478c-2.728 1.017-5.782-.321-6.764-2.996a5.076 5.076 0 0 1 3.054-6.635l9.328-3.478c2.727-1.017 5.782.321 6.764 2.996 1.036 2.622-.328 5.618-3.055 6.635" />
|
||||||
|
<path stroke="var(--color-skin-2, #f6f6f9)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M293.641 24.964c3.982-1.498 7.691-2.3 10.473-2.033M258.73 51.235c6.382-6.1 11.945-11.076 17.236-15.838 2.128-1.873 4.746-3.799 7.582-5.511M209.584 64.88c8.564.748 18.328-.054 28.746-3.532M149.8 42.246c10.855 1.873 19.855 6.528 30.601 12.627" />
|
||||||
|
<path fill="var(--color-skin-3, #eeedf3)"
|
||||||
|
d="M80.09 177.027c-26.183 11.772-37.092 19.798-31.092 41.735s32.182 21.937 32.182 21.937-4.363 13.377 9.819 14.982 30.001-2.675 30.546-21.937c.546-19.262-10.909-32.639-34.364-34.779" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826"
|
||||||
|
d="M80.09 177.027c-26.183 11.772-37.092 19.798-31.092 41.735s32.182 21.937 32.182 21.937-4.363 13.377 9.819 14.982 30.001-2.675 30.546-21.937c.546-19.262-10.909-32.639-34.364-34.779" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)" d="M81.18 240.699c5.454 1.07 13.636 1.07 13.636 1.07z" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826" d="M81.18 240.699c5.454 1.07 13.636 1.07 13.636 1.07" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)"
|
||||||
|
d="m103.654 210.041 3.545 3.639c2.673 2.729 2.564 7.116-.218 9.738l-1.036.963c-2.782 2.622-7.255 2.515-9.928-.214l-3.545-3.638c-2.673-2.729-2.564-7.117.218-9.738l1.036-.964c2.782-2.621 7.255-2.514 9.928.214" />
|
||||||
|
<path fill="var(--color-skin-3, #eeedf3)"
|
||||||
|
d="m169.001 136.15-3.546 1.391c-2.672 1.016-5.727-.268-6.763-2.89l-.382-.963c-1.037-2.621.273-5.618 2.945-6.634l3.546-1.392c2.673-1.016 5.727.268 6.764 2.89l.382.963c1.09 2.622-.273 5.618-2.946 6.635" />
|
||||||
|
<path fill="var(--color-skin-2, #f6f6f9)" d="M71.744 173.015c.982-7.491.327-11.825.327-11.825z" />
|
||||||
|
<path stroke="var(--color-outline, #b2b1c8)" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"
|
||||||
|
stroke-width="1.826" d="M71.744 173.015c.982-7.491.327-11.825.327-11.825" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="a">
|
||||||
|
<path fill="var(--color-highlight, #fff)" d="M0 0h333.333v333.333H0z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 13 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 11 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 11 KiB |
@ -277,7 +277,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
if (account?.mute_expires_at) {
|
if (account?.mute_expires_at) {
|
||||||
muteTimeRemaining = (
|
muteTimeRemaining = (
|
||||||
<>
|
<>
|
||||||
· <RelativeTimestamp timestamp={account.mute_expires_at} />
|
· <RelativeTimestamp hasFuture timestamp={account.mute_expires_at} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -149,11 +149,7 @@ export const AccountListItem: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{account.last_status_at ? (
|
{account.last_status_at ? (
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp long timestamp={account.last_status_at} />
|
||||||
long
|
|
||||||
timestamp={account.last_status_at}
|
|
||||||
noFuture
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -52,8 +52,10 @@ export const Badge: FC<BadgeProps> = ({
|
|||||||
data-account-role-id={roleId}
|
data-account-role-id={roleId}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<span>{label}</span>
|
<span className={classes.content}>
|
||||||
{domain && <span className={classes.domain}>{domain}</span>}
|
{label}
|
||||||
|
{domain && <span className={classes.domain}> {domain}</span>}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
|
||||||
> svg {
|
> svg {
|
||||||
|
flex-shrink: 0;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
@ -26,6 +26,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.domain {
|
.domain {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export const Poll: React.FC<PollProps> = ({ pollId, disabled, status }) => {
|
|||||||
if (expired) {
|
if (expired) {
|
||||||
return intl.formatMessage(messages.closed);
|
return intl.formatMessage(messages.closed);
|
||||||
}
|
}
|
||||||
return <RelativeTimestamp timestamp={poll.expires_at} />;
|
return <RelativeTimestamp hasFuture timestamp={poll.expires_at} />;
|
||||||
}, [expired, intl, poll]);
|
}, [expired, intl, poll]);
|
||||||
const votesCount = useMemo(() => {
|
const votesCount = useMemo(() => {
|
||||||
if (!poll) {
|
if (!poll) {
|
||||||
|
|||||||
@ -23,16 +23,16 @@ export const RelativeTimestamp: FC<{
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
long?: boolean;
|
long?: boolean;
|
||||||
noTime?: boolean;
|
noTime?: boolean;
|
||||||
noFuture?: boolean;
|
hasFuture?: boolean;
|
||||||
}> = ({ timestamp, long = false, noTime = false, noFuture = false }) => {
|
}> = ({ timestamp, long = false, noTime = false, hasFuture = false }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
const date = useMemo(() => {
|
const date = useMemo(() => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return noFuture ? new Date(Math.min(date.getTime(), now)) : date;
|
return !hasFuture ? new Date(Math.min(date.getTime(), now)) : date;
|
||||||
}, [noFuture, now, timestamp]);
|
}, [hasFuture, now, timestamp]);
|
||||||
const ts = date.getTime();
|
const ts = date.getTime();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ const meta = {
|
|||||||
timestamp: new Date(Date.now() - DAY * 3).toISOString(),
|
timestamp: new Date(Date.now() - DAY * 3).toISOString(),
|
||||||
long: false,
|
long: false,
|
||||||
noTime: false,
|
noTime: false,
|
||||||
noFuture: false,
|
hasFuture: false,
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
timestamp: {
|
timestamp: {
|
||||||
@ -44,10 +44,10 @@ export const DateOnly: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoFuture: Story = {
|
export const HasFuture: Story = {
|
||||||
args: {
|
args: {
|
||||||
timestamp: new Date(Date.now() + DAY * 3).toISOString(),
|
timestamp: new Date(Date.now() + DAY * 3).toISOString(),
|
||||||
noFuture: true,
|
hasFuture: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -56,10 +56,7 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
|
|||||||
className='status__relative-time'
|
className='status__relative-time'
|
||||||
>
|
>
|
||||||
<StatusVisibility visibility={status.get('visibility')} />
|
<StatusVisibility visibility={status.get('visibility')} />
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp timestamp={status.get('created_at') as string} />
|
||||||
timestamp={status.get('created_at') as string}
|
|
||||||
noFuture
|
|
||||||
/>
|
|
||||||
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
107
app/javascript/mastodon/components/truncated_list/index.tsx
Normal file
107
app/javascript/mastodon/components/truncated_list/index.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { Article } from '@/mastodon/components/scrollable_list/components';
|
||||||
|
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||||
|
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
||||||
|
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
import type { IconProp } from '../icon';
|
||||||
|
import { ListItemButton, ListItemWrapper } from '../list_item';
|
||||||
|
|
||||||
|
export interface TruncatedListItemInfo<TListItem> {
|
||||||
|
item: TListItem;
|
||||||
|
index: number;
|
||||||
|
totalListLength: number;
|
||||||
|
isLastElement: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleButtonOptions {
|
||||||
|
title: NonNullable<React.ReactNode>;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
icon?: IconProp;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TruncatedListProps<TListItem> {
|
||||||
|
visibleItems: TListItem[];
|
||||||
|
truncatedItems: TListItem[];
|
||||||
|
renderListItem: (
|
||||||
|
itemInfo: TruncatedListItemInfo<TListItem>,
|
||||||
|
) => React.ReactElement;
|
||||||
|
toggleButton: ToggleButtonOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate the children of an `ItemList` component with this helper
|
||||||
|
* component.
|
||||||
|
* It handles rendering the children with correct indexes for accessibility,
|
||||||
|
* and has a configurable toggle button.
|
||||||
|
*/
|
||||||
|
export const TruncatedListItems = <TListItem,>({
|
||||||
|
visibleItems,
|
||||||
|
truncatedItems,
|
||||||
|
toggleButton,
|
||||||
|
renderListItem,
|
||||||
|
}: TruncatedListProps<TListItem>) => {
|
||||||
|
const [showTruncatedItems, setShowTruncatedItems] = useState(false);
|
||||||
|
const toggleTruncatedItems = useCallback(() => {
|
||||||
|
setShowTruncatedItems((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasHiddenAccounts = truncatedItems.length > 0;
|
||||||
|
// Add the toggle button's item to the list size when needed
|
||||||
|
const initialListSize = visibleItems.length + (hasHiddenAccounts ? 1 : 0);
|
||||||
|
const totalListLength =
|
||||||
|
initialListSize + (showTruncatedItems ? truncatedItems.length : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visibleItems.map((item, index) => {
|
||||||
|
return renderListItem({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement:
|
||||||
|
index === visibleItems.length - 1 && !hasHiddenAccounts,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
{hasHiddenAccounts && (
|
||||||
|
<Article aria-posinset={initialListSize} aria-setsize={totalListLength}>
|
||||||
|
<ListItemWrapper
|
||||||
|
icon={
|
||||||
|
toggleButton.icon && (
|
||||||
|
<Icon id='toggle-icon' icon={toggleButton.icon} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconEnd={
|
||||||
|
<Icon
|
||||||
|
id='open-status'
|
||||||
|
icon={
|
||||||
|
showTruncatedItems
|
||||||
|
? KeyboardArrowUpIcon
|
||||||
|
: KeyboardArrowDownIcon
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
aria-expanded={showTruncatedItems}
|
||||||
|
onClick={toggleTruncatedItems}
|
||||||
|
subtitle={toggleButton.subtitle}
|
||||||
|
>
|
||||||
|
{toggleButton.title}
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItemWrapper>
|
||||||
|
</Article>
|
||||||
|
)}
|
||||||
|
{showTruncatedItems &&
|
||||||
|
truncatedItems.map((item, index) => {
|
||||||
|
return renderListItem({
|
||||||
|
item,
|
||||||
|
index: initialListSize + index + 1,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement: index === truncatedItems.length - 1,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
[data-color-scheme='dark'] .image {
|
||||||
|
--color-skin-1: #3a3a50;
|
||||||
|
--color-skin-2: #67678e;
|
||||||
|
--color-skin-3: #44445f;
|
||||||
|
--color-outline: #21212c;
|
||||||
|
--color-shadow: #181820;
|
||||||
|
--color-highlight: #b2b1c8;
|
||||||
|
}
|
||||||
@ -5,17 +5,17 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import ElephantDarkImage from '@/images/elephant_ui_dark.svg?react';
|
import ElephantImage from '@/images/elephant_ui.svg?react';
|
||||||
import ElephantLightImage from '@/images/elephant_ui_light.svg?react';
|
|
||||||
import { openModal } from '@/mastodon/actions/modal';
|
import { openModal } from '@/mastodon/actions/modal';
|
||||||
import { Button } from '@/mastodon/components/button';
|
import { Button } from '@/mastodon/components/button';
|
||||||
import { EmptyState } from '@/mastodon/components/empty_state';
|
import { EmptyState } from '@/mastodon/components/empty_state';
|
||||||
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
|
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
|
||||||
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
|
import { areCollectionsEnabled } from '@/mastodon/features/collections/utils';
|
||||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||||
import { useTheme } from '@/mastodon/hooks/useTheme';
|
|
||||||
import { useAppDispatch } from '@/mastodon/store';
|
import { useAppDispatch } from '@/mastodon/store';
|
||||||
|
|
||||||
|
import classes from './empty_message.module.scss';
|
||||||
|
|
||||||
interface EmptyMessageProps {
|
interface EmptyMessageProps {
|
||||||
suspended: boolean;
|
suspended: boolean;
|
||||||
hidden: boolean;
|
hidden: boolean;
|
||||||
@ -33,9 +33,6 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { acct } = useParams<{ acct?: string }>();
|
const { acct } = useParams<{ acct?: string }>();
|
||||||
const me = useCurrentAccountId();
|
const me = useCurrentAccountId();
|
||||||
const theme = useTheme();
|
|
||||||
const ElephantImage =
|
|
||||||
theme === 'dark' ? ElephantDarkImage : ElephantLightImage;
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@ -57,7 +54,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
|
|||||||
|
|
||||||
const hasCollections = areCollectionsEnabled();
|
const hasCollections = areCollectionsEnabled();
|
||||||
|
|
||||||
const image = <ElephantImage />;
|
const image = <ElephantImage className={classes.image} />;
|
||||||
|
|
||||||
if (me === accountId) {
|
if (me === accountId) {
|
||||||
if (hasCollections) {
|
if (hasCollections) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
@ -6,10 +6,9 @@ import { useHistory } from 'react-router';
|
|||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import { AccountListItem } from '@/mastodon/components/account_list_item';
|
|
||||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
|
||||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
|
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
|
||||||
|
import { AccountListItem } from 'mastodon/components/account_list_item';
|
||||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||||
@ -18,9 +17,12 @@ import {
|
|||||||
ItemList,
|
ItemList,
|
||||||
Scrollable,
|
Scrollable,
|
||||||
} from 'mastodon/components/scrollable_list/components';
|
} from 'mastodon/components/scrollable_list/components';
|
||||||
|
import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list';
|
||||||
|
import { TruncatedListItems } from 'mastodon/components/truncated_list';
|
||||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
import Column from 'mastodon/features/ui/components/column';
|
||||||
|
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||||
import {
|
import {
|
||||||
@ -74,11 +76,32 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||||||
const { collections, status: collectionsLoadStatus } = useAppSelector(
|
const { collections, status: collectionsLoadStatus } = useAppSelector(
|
||||||
(state) => selectAccountCollections(state, accountId ?? null),
|
(state) => selectAccountCollections(state, accountId ?? null),
|
||||||
);
|
);
|
||||||
const listedCollections = collections.filter(
|
|
||||||
// Hide unlisted and empty collections to avoid confusion
|
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
|
||||||
// (Unlisted collections will only be part of the payload
|
collections,
|
||||||
// when viewing your own profile.)
|
(item) =>
|
||||||
(item) => item.discoverable && !!item.item_count,
|
item.discoverable && !!item.item_count
|
||||||
|
? 'listedCollections'
|
||||||
|
: 'unlistedCollections',
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderListItem = useCallback(
|
||||||
|
({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement,
|
||||||
|
}: TruncatedListItemInfo<(typeof listedCollections)[number]>) => (
|
||||||
|
<CollectionListItem
|
||||||
|
key={item.id}
|
||||||
|
collection={item}
|
||||||
|
withoutBorder={isLastElement}
|
||||||
|
withAuthorHandle={false}
|
||||||
|
positionInList={index}
|
||||||
|
listSize={totalListLength}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasCollections =
|
const hasCollections =
|
||||||
@ -167,16 +190,26 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||||||
</Subheading>
|
</Subheading>
|
||||||
{hasCollections ? (
|
{hasCollections ? (
|
||||||
<ItemList>
|
<ItemList>
|
||||||
{listedCollections.map((item, index) => (
|
<TruncatedListItems
|
||||||
<CollectionListItem
|
visibleItems={listedCollections}
|
||||||
key={item.id}
|
truncatedItems={unlistedCollections}
|
||||||
collection={item}
|
toggleButton={{
|
||||||
withoutBorder={index === listedCollections.length - 1}
|
title: (
|
||||||
withAuthorHandle={false}
|
<FormattedMessage
|
||||||
positionInList={index + 1}
|
id='collections.unlisted_collections_with_count'
|
||||||
listSize={listedCollections.length}
|
defaultMessage='Unlisted collections ({count})'
|
||||||
/>
|
values={{ count: unlistedCollections.length }}
|
||||||
))}
|
/>
|
||||||
|
),
|
||||||
|
subtitle: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.unlisted_collections_description'
|
||||||
|
defaultMessage='These don’t appear on your profile to others. Anyone with the link can discover them.'
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
renderListItem={renderListItem}
|
||||||
|
/>
|
||||||
</ItemList>
|
</ItemList>
|
||||||
) : (
|
) : (
|
||||||
<EmptyMessage
|
<EmptyMessage
|
||||||
|
|||||||
@ -36,10 +36,7 @@ export const AccountStatusHeader: FC<StatusHeaderProps> = ({
|
|||||||
className='status__relative-time'
|
className='status__relative-time'
|
||||||
>
|
>
|
||||||
<StatusVisibility visibility={status.get('visibility')} />
|
<StatusVisibility visibility={status.get('visibility')} />
|
||||||
<RelativeTimestamp
|
<RelativeTimestamp timestamp={status.get('created_at') as string} />
|
||||||
timestamp={status.get('created_at') as string}
|
|
||||||
noFuture
|
|
||||||
/>
|
|
||||||
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
{editedAt && <StatusEditedAt editedAt={editedAt} />}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@ -2,13 +2,6 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
|||||||
|
|
||||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import {
|
|
||||||
ListItemButton,
|
|
||||||
ListItemWrapper,
|
|
||||||
} from '@/mastodon/components/list_item';
|
|
||||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
|
||||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
|
||||||
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||||
import type { RenderButtonOptions } from 'mastodon/components/account_list_item';
|
import type { RenderButtonOptions } from 'mastodon/components/account_list_item';
|
||||||
@ -18,13 +11,15 @@ import {
|
|||||||
} from 'mastodon/components/account_list_item';
|
} from 'mastodon/components/account_list_item';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { Callout } from 'mastodon/components/callout';
|
import { Callout } from 'mastodon/components/callout';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import {
|
import {
|
||||||
Article,
|
Article,
|
||||||
ItemList,
|
ItemList,
|
||||||
} from 'mastodon/components/scrollable_list/components';
|
} from 'mastodon/components/scrollable_list/components';
|
||||||
|
import type { TruncatedListItemInfo } from 'mastodon/components/truncated_list';
|
||||||
|
import { TruncatedListItems } from 'mastodon/components/truncated_list';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
import { createAppSelector, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
|
import { useConfirmRevoke } from './revoke_collection_inclusion_modal';
|
||||||
import classes from './styles.module.scss';
|
import classes from './styles.module.scss';
|
||||||
@ -99,13 +94,9 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const confirmRevoke = useConfirmRevoke(collection);
|
const confirmRevoke = useConfirmRevoke(collection);
|
||||||
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
const listHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||||
const [canShowHiddenAccounts, setCanShowHiddenAccounts] = useState(false);
|
|
||||||
const toggleHiddenAccounts = useCallback(() => {
|
|
||||||
setCanShowHiddenAccounts((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isOwnCollection = collection?.account_id === me;
|
const isOwnCollection = collection?.account_id === me;
|
||||||
const { items = [], account_id: collectionOwnerId, id } = collection ?? {};
|
const { account_id: collectionOwnerId, id } = collection ?? {};
|
||||||
|
|
||||||
const relationships = useAppSelector((state) => state.relationships);
|
const relationships = useAppSelector((state) => state.relationships);
|
||||||
const collectionAccounts = useAppSelector((state) =>
|
const collectionAccounts = useAppSelector((state) =>
|
||||||
@ -135,9 +126,6 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
return { visibleAccounts, hiddenAccounts };
|
return { visibleAccounts, hiddenAccounts };
|
||||||
}, [collectionAccounts, relationships]);
|
}, [collectionAccounts, relationships]);
|
||||||
|
|
||||||
const hasHiddenAccounts = hiddenAccounts.length > 0;
|
|
||||||
const initialListSize = visibleAccounts.length + (hasHiddenAccounts ? 1 : 0);
|
|
||||||
|
|
||||||
const renderAccountItemButton = useCallback(
|
const renderAccountItemButton = useCallback(
|
||||||
({ relationship, accountId }: RenderButtonOptions) => {
|
({ relationship, accountId }: RenderButtonOptions) => {
|
||||||
// When viewing your own collection, only show the Follow button
|
// When viewing your own collection, only show the Follow button
|
||||||
@ -165,6 +153,28 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
[collectionOwnerId, confirmRevoke],
|
[collectionOwnerId, confirmRevoke],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderListItem = useCallback(
|
||||||
|
({
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
totalListLength,
|
||||||
|
isLastElement,
|
||||||
|
}: TruncatedListItemInfo<Account>) => (
|
||||||
|
<Article
|
||||||
|
key={item.id}
|
||||||
|
aria-posinset={index + 1}
|
||||||
|
aria-setsize={totalListLength}
|
||||||
|
>
|
||||||
|
<AccountListItem
|
||||||
|
accountId={item.id}
|
||||||
|
withBorder={!isLastElement}
|
||||||
|
renderButton={renderAccountItemButton}
|
||||||
|
/>
|
||||||
|
</Article>
|
||||||
|
),
|
||||||
|
[renderAccountItemButton],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h3
|
<h3
|
||||||
@ -194,71 +204,28 @@ export const CollectionAccountsList: React.FC<{
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage={intl.formatMessage(messages.empty)}
|
emptyMessage={intl.formatMessage(messages.empty)}
|
||||||
>
|
>
|
||||||
{visibleAccounts.map(({ id }, index) => (
|
<TruncatedListItems
|
||||||
<Article
|
visibleItems={visibleAccounts}
|
||||||
key={id}
|
truncatedItems={hiddenAccounts}
|
||||||
aria-posinset={index + 1}
|
toggleButton={{
|
||||||
aria-setsize={initialListSize}
|
icon: VisibilityOffIcon,
|
||||||
>
|
title: (
|
||||||
<AccountListItem
|
<FormattedMessage
|
||||||
accountId={id}
|
id='collections.hidden_accounts_link'
|
||||||
withBorder={index !== items.length - 1 || hasHiddenAccounts}
|
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
||||||
renderButton={renderAccountItemButton}
|
values={{ count: hiddenAccounts.length }}
|
||||||
/>
|
|
||||||
</Article>
|
|
||||||
))}
|
|
||||||
{hasHiddenAccounts && (
|
|
||||||
<Article
|
|
||||||
aria-posinset={initialListSize}
|
|
||||||
aria-setsize={initialListSize}
|
|
||||||
>
|
|
||||||
<ListItemWrapper
|
|
||||||
icon={<Icon id='visibility-off' icon={VisibilityOffIcon} />}
|
|
||||||
iconEnd={
|
|
||||||
<Icon
|
|
||||||
id='open-status'
|
|
||||||
icon={
|
|
||||||
canShowHiddenAccounts
|
|
||||||
? KeyboardArrowUpIcon
|
|
||||||
: KeyboardArrowDownIcon
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemButton
|
|
||||||
aria-expanded={canShowHiddenAccounts}
|
|
||||||
onClick={toggleHiddenAccounts}
|
|
||||||
subtitle={
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.hidden_accounts_description'
|
|
||||||
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
|
||||||
values={{ count: hiddenAccounts.length }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormattedMessage
|
|
||||||
id='collections.hidden_accounts_link'
|
|
||||||
defaultMessage='{count, plural, one {# hidden account} other {# hidden accounts}}'
|
|
||||||
values={{ count: hiddenAccounts.length }}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
</ListItemWrapper>
|
|
||||||
</Article>
|
|
||||||
)}
|
|
||||||
{canShowHiddenAccounts &&
|
|
||||||
hiddenAccounts.map(({ id }, index) => (
|
|
||||||
<Article
|
|
||||||
key={id}
|
|
||||||
aria-posinset={initialListSize + index + 1}
|
|
||||||
aria-setsize={initialListSize + hiddenAccounts.length}
|
|
||||||
>
|
|
||||||
<AccountListItem
|
|
||||||
accountId={id}
|
|
||||||
withBorder={index !== hiddenAccounts.length - 1}
|
|
||||||
renderButton={renderAccountItemButton}
|
|
||||||
/>
|
/>
|
||||||
</Article>
|
),
|
||||||
))}
|
subtitle: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='collections.hidden_accounts_description'
|
||||||
|
defaultMessage='You’ve blocked or muted {count, plural, one {this user} other {these users}}'
|
||||||
|
values={{ count: hiddenAccounts.length }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
renderListItem={renderListItem}
|
||||||
|
/>
|
||||||
</ItemList>
|
</ItemList>
|
||||||
</SensitiveScreen>
|
</SensitiveScreen>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -409,6 +409,8 @@
|
|||||||
"collections.sensitive": "Sensitive",
|
"collections.sensitive": "Sensitive",
|
||||||
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
"collections.topic_hint": "Add a hashtag that helps others understand the main topic of this collection.",
|
||||||
"collections.topic_special_chars_hint": "Special characters will be removed when saving",
|
"collections.topic_special_chars_hint": "Special characters will be removed when saving",
|
||||||
|
"collections.unlisted_collections_description": "These don’t appear on your profile to others. Anyone with the link can discover them.",
|
||||||
|
"collections.unlisted_collections_with_count": "Unlisted collections ({count})",
|
||||||
"collections.view_collection": "View collection",
|
"collections.view_collection": "View collection",
|
||||||
"collections.view_other_collections_by_user": "View other collections by this user",
|
"collections.view_other_collections_by_user": "View other collections by this user",
|
||||||
"collections.visibility_public": "Public",
|
"collections.visibility_public": "Public",
|
||||||
|
|||||||
@ -10657,14 +10657,7 @@ noscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__source {
|
&__source {
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: help;
|
cursor: help;
|
||||||
|
|
||||||
> span {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,7 @@ class EmailSubscription < ApplicationRecord
|
|||||||
|
|
||||||
normalizes :email, with: ->(str) { str.squish.downcase }
|
normalizes :email, with: ->(str) { str.squish.downcase }
|
||||||
|
|
||||||
validates :email, presence: true, email_address: true, uniqueness: { scope: :account_id }
|
validates :email, presence: true, email_address: true, length: { maximum: 320 }, uniqueness: { scope: :account_id }
|
||||||
validates :email, email_mx: true, if: -> { email_changed? && !Rails.env.local? }
|
validates :email, email_mx: true, if: -> { email_changed? && !Rails.env.local? }
|
||||||
|
|
||||||
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
scope :confirmed, -> { where.not(confirmed_at: nil) }
|
||||||
|
|||||||
@ -92,7 +92,7 @@ class User < ApplicationRecord
|
|||||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
|
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? && !Setting.require_invite_text }
|
||||||
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
validates :invite_request, presence: true, on: :create, if: :invite_text_required?
|
||||||
|
|
||||||
validates :email, presence: true, email_address: true
|
validates :email, presence: true, email_address: true, length: { maximum: 320 }
|
||||||
validates :email, email_mx: { attempt_ip: :sign_up_ip }, if: :validate_email_dns?
|
validates :email, email_mx: { attempt_ip: :sign_up_ip }, if: :validate_email_dns?
|
||||||
|
|
||||||
validates_with UserEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
validates_with UserEmailValidator, if: -> { ENV['EMAIL_DOMAIN_LISTS_APPLY_AFTER_CONFIRMATION'] == 'true' || !confirmed? }
|
||||||
|
|||||||
@ -11,8 +11,14 @@ class EmailAddressValidator < ActiveModel::EachValidator
|
|||||||
value = value.strip
|
value = value.strip
|
||||||
|
|
||||||
address = Mail::Address.new(value)
|
address = Mail::Address.new(value)
|
||||||
record.errors.add(attribute, :invalid) if address.address != value
|
record.errors.add(attribute, :invalid) if address.address != value || contains_disallowed_characters?(value)
|
||||||
rescue Mail::Field::FieldError
|
rescue Mail::Field::FieldError
|
||||||
record.errors.add(attribute, :invalid)
|
record.errors.add(attribute, :invalid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def contains_disallowed_characters?(value)
|
||||||
|
value.include?('%') || value.include?(',') || value.include?('"')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -59,7 +59,7 @@ services:
|
|||||||
web:
|
web:
|
||||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||||
# build: .
|
# build: .
|
||||||
image: ghcr.io/glitch-soc/mastodon:v4.5.8
|
image: ghcr.io/glitch-soc/mastodon:v4.5.9
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec puma -C config/puma.rb
|
command: bundle exec puma -C config/puma.rb
|
||||||
@ -83,7 +83,7 @@ services:
|
|||||||
# build:
|
# build:
|
||||||
# dockerfile: ./streaming/Dockerfile
|
# dockerfile: ./streaming/Dockerfile
|
||||||
# context: .
|
# context: .
|
||||||
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.8
|
image: ghcr.io/glitch-soc/mastodon-streaming:v4.5.9
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: node ./streaming/index.js
|
command: node ./streaming/index.js
|
||||||
@ -102,7 +102,7 @@ services:
|
|||||||
sidekiq:
|
sidekiq:
|
||||||
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
# You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
|
||||||
# build: .
|
# build: .
|
||||||
image: ghcr.io/glitch-soc/mastodon:v4.5.8
|
image: ghcr.io/glitch-soc/mastodon:v4.5.9
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bundle exec sidekiq
|
command: bundle exec sidekiq
|
||||||
|
|||||||
BIN
docs/hero-nodes.gif
Normal file
BIN
docs/hero-nodes.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
@ -17,7 +17,7 @@ module Mastodon
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_prerelease
|
def default_prerelease
|
||||||
'alpha.6'
|
'alpha.7'
|
||||||
end
|
end
|
||||||
|
|
||||||
def prerelease
|
def prerelease
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user