Merge pull request #3409 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 3df8fb8fe9
This commit is contained in:
Claire
2026-02-18 20:15:48 +01:00
committed by GitHub
185 changed files with 2041 additions and 1041 deletions

View File

@@ -154,9 +154,15 @@
groupName: 'opentelemetry-ruby (non-major)',
},
{
// Group Playwright Ruby & JS deps in the same PR, as they need to be in sync
matchManagers: ['bundler', 'npm'],
matchPackageNames: ['playwright-ruby-client', 'playwright'],
// The ruby portion of the Playwright group
matchManagers: ['bundler'],
matchPackageNames: ['playwright-ruby-client'],
groupName: 'Playwright',
},
{
// The node portion of the Playwright group
matchManagers: ['npm'],
matchPackageNames: ['playwright'],
groupName: 'Playwright',
},
// Add labels depending on package manager

View File

@@ -4,3 +4,6 @@ Layout/FirstHashElementIndentation:
Layout/LineLength:
Max: 300 # Default of 120 causes a duplicate entry in generated todo file
Layout/MultilineMethodCallIndentation:
EnforcedStyle: indented

View File

@@ -470,7 +470,7 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.0)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
oj (3.16.15)
@@ -631,7 +631,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack (3.2.5)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-cors (3.0.0)
@@ -741,7 +741,7 @@ GEM
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.2)
rspec-rails (8.0.3)
actionpack (>= 7.2)
activesupport (>= 7.2)
railties (>= 7.2)
@@ -755,7 +755,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.7)
rubocop (1.84.0)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)

View File

@@ -53,9 +53,9 @@ class PublicStatusesIndex < Chewy::Index
}
index_scope ::Status.unscoped
.kept
.indexable
.includes(:media_attachments, :preloadable_poll, :tags, preview_cards_status: :preview_card)
.kept
.indexable
.includes(:media_attachments, :preloadable_poll, :tags, preview_cards_status: :preview_card)
root date_detection: false do
field(:id, type: 'long')

View File

@@ -5,8 +5,8 @@ class Admin::Fasp::Debug::CallbacksController < Admin::BaseController
authorize [:admin, :fasp, :provider], :update?
@callbacks = Fasp::DebugCallback
.includes(:fasp_provider)
.order(created_at: :desc)
.includes(:fasp_provider)
.order(created_at: :desc)
end
def destroy

View File

@@ -18,14 +18,14 @@ class Api::V1::BlocksController < Api::BaseController
def paginated_blocks
@paginated_blocks ||= Block.eager_load(target_account: [:account_stat, :user])
.joins(:target_account)
.merge(Account.without_suspended)
.where(account: current_account)
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
.joins(:target_account)
.merge(Account.without_suspended)
.where(account: current_account)
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def next_path

View File

@@ -37,20 +37,20 @@ class Api::V1::ConversationsController < Api::BaseController
def paginated_conversations
AccountConversation.where(account: current_account)
.includes(
account: [:account_stat, user: :role],
last_status: [
:media_attachments,
:status_stat,
:tags,
{
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
active_mentions: :account,
account: [:account_stat, user: :role],
},
]
)
.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
.includes(
account: [:account_stat, user: :role],
last_status: [
:media_attachments,
:status_stat,
:tags,
{
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
active_mentions: :account,
account: [:account_stat, user: :role],
},
]
)
.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end
def next_path

View File

@@ -18,14 +18,14 @@ class Api::V1::MutesController < Api::BaseController
def paginated_mutes
@paginated_mutes ||= Mute.eager_load(target_account: [:account_stat, :user])
.joins(:target_account)
.merge(Account.without_suspended)
.where(account: current_account)
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
.joins(:target_account)
.merge(Account.without_suspended)
.where(account: current_account)
.paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT),
params[:max_id],
params[:since_id]
)
end
def next_path

View File

@@ -72,10 +72,10 @@ class Api::V1Alpha::CollectionsController < Api::BaseController
def set_collections
@collections = @account.collections
.with_tag
.order(created_at: :desc)
.offset(offset_param)
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
.with_tag
.order(created_at: :desc)
.offset(offset_param)
.limit(limit_param(DEFAULT_COLLECTIONS_LIMIT))
@collections = @collections.discoverable unless @account == current_account
end

View File

@@ -11,14 +11,14 @@ export interface ApiCollectionJSON {
account_id: string;
id: string;
uri: string;
uri: string | null;
local: boolean;
item_count: number;
name: string;
description: string;
tag?: ApiTagJSON;
language: string;
tag: ApiTagJSON | null;
language: string | null;
sensitive: boolean;
discoverable: boolean;

View File

@@ -298,7 +298,7 @@ export const Account: React.FC<AccountProps> = ({
>
<div className='account__info-wrapper'>
<Permalink
className='account__display-name'
className='account__display-name focusable'
title={account?.acct}
href={account?.url}
to={`/@${account?.acct}`}

View File

@@ -53,7 +53,7 @@ export const Avatar: React.FC<Props> = ({
}, [setError]);
const avatar = (
<div
<span
className={classNames(className, 'account__avatar', {
'account__avatar--inline': inline,
'account__avatar--loading': loading,
@@ -68,14 +68,14 @@ export const Avatar: React.FC<Props> = ({
)}
{counter && (
<div
<span
className='account__avatar__counter'
style={{ borderColor: counterBorderColor }}
>
{counter}
</div>
</span>
)}
</div>
</span>
);
if (withLink) {

View File

@@ -1,6 +1,8 @@
import { forwardRef, useRef, useImperativeHandle } from 'react';
import type { Ref } from 'react';
import classNames from 'classnames';
import { scrollTop } from 'flavours/glitch/scroll';
export interface ColumnRef {
@@ -12,10 +14,11 @@ interface ColumnProps {
children?: React.ReactNode;
label?: string;
bindToDocument?: boolean;
className?: string;
}
export const Column = forwardRef<ColumnRef, ColumnProps>(
({ children, label, bindToDocument }, ref: Ref<ColumnRef>) => {
({ children, label, bindToDocument, className }, ref: Ref<ColumnRef>) => {
const nodeRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
@@ -39,7 +42,12 @@ export const Column = forwardRef<ColumnRef, ColumnProps>(
}));
return (
<div role='region' aria-label={label} className='column' ref={nodeRef}>
<div
role='region'
aria-label={label}
className={classNames('column', className)}
ref={nodeRef}
>
{children}
</div>
);

View File

@@ -73,6 +73,7 @@ export interface Props {
iconComponent?: IconProp;
active?: boolean;
children?: React.ReactNode;
className?: string;
pinned?: boolean;
multiColumn?: boolean;
extraButton?: React.ReactNode;
@@ -91,6 +92,7 @@ export const ColumnHeader: React.FC<Props> = ({
iconComponent,
active,
children,
className,
pinned,
multiColumn,
extraButton,
@@ -141,7 +143,7 @@ export const ColumnHeader: React.FC<Props> = ({
onPin?.();
}, [history, pinned, onPin]);
const wrapperClassName = classNames('column-header__wrapper', {
const wrapperClassName = classNames('column-header__wrapper', className, {
active,
});
@@ -256,7 +258,8 @@ export const ColumnHeader: React.FC<Props> = ({
}
const hasIcon = icon && iconComponent;
const hasTitle = hasIcon && title;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const hasTitle = (hasIcon || backButton) && title;
const component = (
<div className={wrapperClassName}>
@@ -270,7 +273,7 @@ export const ColumnHeader: React.FC<Props> = ({
className='column-header__title'
type='button'
>
{!backButton && (
{!backButton && hasIcon && (
<Icon
id={icon}
icon={iconComponent}

View File

@@ -311,6 +311,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
status?: ImmutableMap<string, unknown>;
needsStatusRefresh?: boolean;
forceDropdown?: boolean;
className?: string;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: // Must use a union type for the full function as a union with void is not allowed.
@@ -335,6 +336,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
status,
needsStatusRefresh,
forceDropdown = false,
className,
renderItem,
renderHeader,
onOpen,
@@ -434,6 +436,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
modalProps: {
actions: items,
onClick: handleItemClick,
className,
},
}),
);
@@ -462,6 +465,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
handleClose,
statusId,
needsStatusRefresh,
className,
],
);
@@ -515,7 +519,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props} id={menuId}>
<div {...props} className={className} id={menuId}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}

View File

@@ -3,8 +3,10 @@ import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { useIdentity } from '@/flavours/glitch/identity_context';
import { isClientFeatureEnabled } from '@/flavours/glitch/utils/environment';
import {
fetchRelationships,
followAccount,
@@ -158,14 +160,24 @@ export const FollowButton: React.FC<{
}
if (accountId === me) {
const buttonClasses = classNames(className, 'button button-secondary', {
'button--compact': compact,
});
if (isClientFeatureEnabled('profile_editing')) {
return (
<Link to='/profile/edit' className={buttonClasses}>
{label}
</Link>
);
}
return (
<a
href='/settings/profile'
target='_blank'
rel='noopener'
className={classNames(className, 'button button-secondary', {
'button--compact': compact,
})}
className={buttonClasses}
>
{label}
</a>

View File

@@ -0,0 +1,53 @@
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Column } from '@/flavours/glitch/components/column';
import { ColumnHeader } from '@/flavours/glitch/components/column_header';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useCurrentAccountId } from '@/flavours/glitch/hooks/useAccountId';
import classes from './styles.module.scss';
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const intl = useIntl();
if (!accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!account) {
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<LoadingIndicator />
</Column>
);
}
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<ColumnHeader
title={intl.formatMessage({
id: 'account_edit.column_title',
defaultMessage: 'Edit Profile',
})}
className={classes.header}
showBackButton
extraButton={
<Link to={`/@${account.acct}`} className='button'>
<FormattedMessage
id='account_edit.column_button'
defaultMessage='Done'
/>
</Link>
}
/>
</Column>
);
};

View File

@@ -0,0 +1,26 @@
.column {
border: 1px solid var(--color-border-primary);
border-top-width: 0;
}
.header {
:global(.column-header__buttons) {
align-items: center;
padding-inline-end: 16px;
height: auto;
}
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 24px 24px 12px;
> h1 {
flex-grow: 1;
font-weight: 600;
font-size: 15px;
}
}

View File

@@ -214,7 +214,10 @@ export const AccountHeader: React.FC<{
<>
<AccountBio
accountId={accountId}
className='account__header__content'
className={classNames(
'account__header__content',
isRedesign && redesignClasses.bio,
)}
/>
<AccountHeaderFields accountId={accountId} />
</>

View File

@@ -14,8 +14,10 @@ import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_dat
import { Icon } from '@/flavours/glitch/components/icon';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import type { Account } from '@/flavours/glitch/models/account';
import { isValidUrl } from '@/flavours/glitch/utils/checks';
import type {
Account,
AccountFieldShape,
} from '@/flavours/glitch/models/account';
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
import IconVerified from '@/images/icons/icon_verified.svg?react';
@@ -76,8 +78,8 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
[account.emojis],
);
const textHasCustomEmoji = useCallback(
(text: string) => {
if (!emojis) {
(text?: string | null) => {
if (!emojis || !text) {
return false;
}
for (const emoji of Object.keys(emojis)) {
@@ -92,62 +94,96 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink({
hashtagAccountId: account.id,
});
const intl = useIntl();
if (account.fields.isEmpty()) {
return null;
}
return (
<CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList}>
{account.fields.map(
(
{ name, name_emojified, value_emojified, value_plain, verified_at },
key,
) => (
<div
key={key}
className={classNames(
classes.fieldRow,
verified_at && classes.fieldVerified,
)}
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<FieldHTML
as='dd'
text={value_plain ?? ''}
textEmojified={value_emojified}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
titleLength={120}
{...htmlHandlers}
/>
{verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</div>
),
)}
{account.fields.map((field, key) => (
<FieldRow
key={key}
{...field.toJSON()}
htmlHandlers={htmlHandlers}
textHasCustomEmoji={textHasCustomEmoji}
/>
))}
</dl>
</CustomEmojiProvider>
);
};
const FieldRow: FC<
{
textHasCustomEmoji: (text?: string | null) => boolean;
htmlHandlers: ReturnType<typeof useElementHandledLink>;
} & AccountFieldShape
> = ({
textHasCustomEmoji,
htmlHandlers,
name,
name_emojified,
value_emojified,
value_plain,
verified_at,
}) => {
const intl = useIntl();
const [showAll, setShowAll] = useState(false);
const handleClick = useCallback(() => {
setShowAll((prev) => !prev);
}, []);
return (
/* eslint-disable -- This method of showing field contents is not very accessible, but it's what we've got for now */
<div
className={classNames(
classes.fieldRow,
verified_at && classes.fieldVerified,
showAll && classes.fieldShowAll,
)}
onClick={handleClick}
/* eslint-enable */
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<dd>
<FieldHTML
as='span'
text={value_plain ?? ''}
textEmojified={value_emojified}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
titleLength={120}
{...htmlHandlers}
/>
{verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</dd>
</div>
);
};
const FieldHTML: FC<
{
as: 'dd' | 'dt';
as?: 'span' | 'dt';
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
@@ -164,11 +200,6 @@ const FieldHTML: FC<
onElement,
...props
}) => {
const [showAll, setShowAll] = useState(false);
const handleClick = useCallback(() => {
setShowAll((prev) => !prev);
}, []);
const handleElement: OnElementHandler = useCallback(
(element, props, children, extra) => {
if (element instanceof HTMLAnchorElement) {
@@ -186,17 +217,13 @@ const FieldHTML: FC<
},
[onElement, textHasCustomEmoji],
);
return (
<EmojiHTML
as={as}
htmlString={textEmojified}
title={showTitleOnLength(text, titleLength)}
className={classNames(
className,
text && isValidUrl(text) && classes.fieldLink,
showAll && classes.fieldShowAll,
)}
onClick={handleClick}
className={className}
onElement={handleElement}
{...props}
/>

View File

@@ -45,6 +45,8 @@ import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
@@ -89,6 +91,7 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
className={classes.buttonMenu}
/>
);
};

View File

@@ -24,11 +24,10 @@
.name {
flex-grow: 1;
font-size: 22px;
white-space: initial;
line-height: normal;
> h1 {
font-size: 22px;
line-height: normal;
white-space: initial;
}
}
@@ -149,11 +148,33 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
border-top: 1px solid var(--color-border-primary);
}
.buttonMenu {
// Override the modal for mobile.
&:global(.actions-modal) {
max-height: none;
}
li :global(.icon) {
width: 20px;
height: 20px;
}
}
.bio {
font-size: 15px;
}
.badge {
background-color: var(--color-bg-secondary);
border: none;
color: var(--color-text-secondary);
font-weight: 500;
padding: 4px;
font-size: 13px;
:global(.account__header__badges) > & {
line-height: 1;
}
> span {
font-weight: unset;
@@ -194,12 +215,13 @@ svg.badgeIcon {
.fieldList {
display: grid;
grid-template-columns: 160px 1fr min-content;
grid-template-columns: 160px 1fr;
column-gap: 12px;
margin: 4px 0 16px;
margin: 16px 0;
border-top: 0.5px solid var(--color-border-primary);
@container (width < 420px) {
grid-template-columns: 100px 1fr min-content;
grid-template-columns: 100px 1fr;
}
}
@@ -208,11 +230,10 @@ svg.badgeIcon {
grid-column: 1 / -1;
align-items: start;
grid-template-columns: subgrid;
padding: 0 4px;
padding: 8px;
border-bottom: 0.5px solid var(--color-border-primary);
> :is(dt, dd) {
margin: 8px 0;
&:not(.fieldShowAll) {
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -227,43 +248,34 @@ svg.badgeIcon {
color: var(--color-text-secondary);
}
&:not(.fieldVerified) > dd {
grid-column: span 2;
> dd {
display: flex;
align-items: center;
gap: 4px;
}
a {
font-weight: 500;
color: var(--color-text-brand);
color: inherit;
text-decoration: none;
transition: 0.2s ease-in-out;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
text-decoration: underline;
}
}
}
.fieldVerified {
background-color: var(--color-bg-brand-softer);
}
.fieldLink:is(dd, dt) {
margin: 0;
}
.fieldLink > a {
display: block;
padding: 8px 0;
background-color: var(--color-bg-success-softer);
}
.fieldVerifiedIcon {
width: 16px;
height: 16px;
margin-top: 8px;
}
.fieldNumbersWrapper {
font-size: 13px;
padding: 0;
a {
@@ -323,10 +335,15 @@ svg.badgeIcon {
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 12px;
padding: 0 12px;
padding: 0 24px;
@container (width >= 500px) {
padding: 0 24px;
@container (width < 500px) {
padding: 0 12px;
a {
flex: 1 1 0px;
text-align: center;
}
}
a {

View File

@@ -39,6 +39,7 @@
cursor: pointer;
display: flex;
align-items: center;
font-size: 15px;
}
}

View File

@@ -1,4 +1,4 @@
.collectionItemWrapper {
.wrapper {
display: flex;
align-items: center;
gap: 16px;
@@ -7,13 +7,13 @@
border-bottom: 1px solid var(--color-border-primary);
}
.collectionItemContent {
.content {
position: relative;
flex-grow: 1;
padding: 15px 5px;
}
.collectionItemLink {
.link {
display: block;
margin-bottom: 2px;
font-size: 15px;
@@ -33,16 +33,19 @@
}
}
.collectionItemInfo {
.info {
font-size: 13px;
color: var(--color-text-secondary);
}
.metaList {
--gap: 0.75ch;
display: flex;
gap: var(--gap);
font-size: 13px;
color: var(--color-text-secondary);
& > li:not(:first-child)::before {
& > li:not(:last-child)::after {
content: '·';
margin-inline-end: var(--gap);
margin-inline-start: var(--gap);
}
}

View File

@@ -0,0 +1,67 @@
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import classes from './collection_list_item.module.scss';
import { CollectionMenu } from './collection_menu';
export const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON;
className?: string;
}> = ({ collection, className }) => {
return (
<ul className={classNames(classes.metaList, className)}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
}}
tagName='li'
/>
</ul>
);
};
export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const { id, name } = collection;
const linkId = useId();
return (
<article
className={classNames(classes.wrapper, 'focusable')}
tabIndex={-1}
aria-labelledby={linkId}
>
<div className={classes.content}>
<h2 id={linkId}>
<Link to={`/collections/${id}`} className={classes.link}>
{name}
</Link>
</h2>
<CollectionMetaData collection={collection} className={classes.info} />
</div>
<CollectionMenu context='list' collection={collection} />
</article>
);
};

View File

@@ -0,0 +1,91 @@
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { IconButton } from 'flavours/glitch/components/icon_button';
import { useAppDispatch } from 'flavours/glitch/store';
import { messages as editorMessages } from '../editor';
const messages = defineMessages({
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
export const CollectionMenu: React.FC<{
collection: ApiCollectionJSON;
context: 'list' | 'collection';
className?: string;
}> = ({ collection, context, className }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { id, name } = collection;
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(() => {
const commonItems = [
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
];
if (context === 'list') {
return [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
...commonItems,
];
} else {
return commonItems;
}
}, [intl, id, handleDeleteClick, context]);
return (
<Dropdown scrollKey='collections' items={menu}>
<IconButton
icon='menu-icon'
iconComponent={MoreVertIcon}
title={intl.formatMessage(messages.more)}
className={className}
/>
</Dropdown>
);
};

View File

@@ -0,0 +1,178 @@
import { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'flavours/glitch/actions/alerts';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Account } from 'flavours/glitch/components/account';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { LinkedDisplayName } from 'flavours/glitch/components/display_name';
import { IconButton } from 'flavours/glitch/components/icon_button';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import { Tag } from 'flavours/glitch/components/tags/tag';
import { useAccount } from 'flavours/glitch/hooks/useAccount';
import { me } from 'flavours/glitch/initial_state';
import { fetchCollection } from 'flavours/glitch/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
import { CollectionMetaData } from './collection_list_item';
import { CollectionMenu } from './collection_menu';
import classes from './styles.module.scss';
const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
loading: {
id: 'collections.detail.loading',
defaultMessage: 'Loading collection…',
},
share: {
id: 'collections.detail.share',
defaultMessage: 'Share this collection',
},
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
});
const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const author = (
<span className={classes.displayNameWithAvatar}>
<Avatar size={18} account={account} />
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />
</span>
);
if (id === me) {
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_you'
defaultMessage='Curated by you'
/>
</p>
);
}
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_author'
defaultMessage='Curated by {author}'
values={{ author }}
/>
</p>
);
};
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection,
}) => {
const intl = useIntl();
const { name, description, tag } = collection;
const dispatch = useAppDispatch();
const handleShare = useCallback(() => {
dispatch(showAlert({ message: 'Collection sharing not yet implemented' }));
}, [dispatch]);
return (
<div className={classes.header}>
<div className={classes.titleWithMenu}>
<div className={classes.titleWrapper}>
{tag && (
// TODO: Make non-interactive tag component
<Tag name={tag.name} className={classes.tag} />
)}
<h2 className={classes.name}>{name}</h2>
</div>
<div className={classes.headerButtonWrapper}>
<IconButton
iconComponent={ShareIcon}
icon='share-icon'
title={intl.formatMessage(messages.share)}
className={classes.iconButton}
onClick={handleShare}
/>
<CollectionMenu
context='collection'
collection={collection}
className={classes.iconButton}
/>
</div>
</div>
{description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} />
<CollectionMetaData
collection={collection}
className={classes.metaData}
/>
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
</div>
);
};
export const CollectionDetailPage: 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 isLoading = !!id && !collection;
useEffect(() => {
if (id) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
const pageTitle = collection?.name ?? intl.formatMessage(messages.loading);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
showBackButton
title={pageTitle}
icon='collection-icon'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='collection-detail'
emptyMessage={intl.formatMessage(messages.empty)}
showLoading={isLoading}
bindToDocument={!multiColumn}
alwaysPrepend
prepend={
collection ? <CollectionHeader collection={collection} /> : null
}
>
{collection?.items.map(({ account_id }) =>
account_id ? (
<Account key={account_id} minimal id={account_id} />
) : null,
)}
</ScrollableList>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@@ -0,0 +1,74 @@
.header {
padding: 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.titleWithMenu {
display: flex;
align-items: start;
gap: 10px;
}
.titleWrapper {
flex-grow: 1;
min-width: 0;
}
.tag {
margin-bottom: 4px;
margin-inline-start: -8px;
}
.name {
font-size: 28px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.description {
font-size: 15px;
margin-top: 8px;
}
.headerButtonWrapper {
display: flex;
gap: 8px;
}
.iconButton {
box-sizing: content-box;
padding: 5px;
border-radius: 4px;
border: 1px solid var(--color-border-primary);
}
.authorNote {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-secondary);
}
.metaData {
margin-top: 16px;
font-size: 15px;
}
.displayNameWithAvatar {
display: inline-flex;
gap: 4px;
align-items: baseline;
a {
color: inherit;
text-decoration: underline;
&:hover,
&:focus {
text-decoration: none;
}
}
> :global(.account__avatar) {
align-self: center;
}
}

View File

@@ -71,7 +71,7 @@ export const CollectionDetails: React.FC<{
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`);
history.push(`/collections/${id}`);
});
} else {
const payload: Partial<ApiCreateCollectionPayload> = {

View File

@@ -68,7 +68,7 @@ export const CollectionSettings: React.FC<{
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`);
history.push(`/collections/${id}`);
});
} else {
const payload: ApiCreateCollectionPayload = {

View File

@@ -1,22 +1,16 @@
import { useEffect, useMemo, useCallback, useId } from 'react';
import { useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { openModal } from 'flavours/glitch/actions/modal';
import type { ApiCollectionJSON } from 'flavours/glitch/api_types/collections';
import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { Dropdown } from 'flavours/glitch/components/dropdown_menu';
import { Icon } from 'flavours/glitch/components/icon';
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import {
fetchAccountCollections,
@@ -24,119 +18,13 @@ import {
} from 'flavours/glitch/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
import { CollectionListItem } from './detail/collection_list_item';
import { messages as editorMessages } from './editor';
import classes from './styles.module.scss';
const messages = defineMessages({
heading: { id: 'column.collections', defaultMessage: 'My collections' },
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
const CollectionItem: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { id, name } = collection;
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
],
[intl, id, handleDeleteClick],
);
const linkId = useId();
return (
<article
className={classNames(classes.collectionItemWrapper, 'focusable')}
tabIndex={-1}
aria-labelledby={linkId}
>
<div className={classes.collectionItemContent}>
<h2 id={linkId}>
<Link
to={`/collections/${id}/edit/details`}
className={classes.collectionItemLink}
>
{name}
</Link>
</h2>
<ul className={classes.collectionItemInfo}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
}}
tagName='li'
/>
</ul>
</div>
<Dropdown
scrollKey='collections'
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</article>
);
};
export const Collections: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
@@ -202,7 +90,7 @@ export const Collections: React.FC<{
bindToDocument={!multiColumn}
>
{collections.map((item) => (
<CollectionItem key={item.id} collection={item} />
<CollectionListItem key={item.id} collection={item} />
))}
</ScrollableList>

View File

@@ -11,8 +11,9 @@ import {
export const ActionsModal: React.FC<{
actions: MenuItem[];
onClick: React.MouseEventHandler;
}> = ({ actions, onClick }) => (
<div className='modal-root__modal actions-modal'>
className?: string;
}> = ({ actions, onClick, className }) => (
<div className={classNames('modal-root__modal actions-modal', className)}>
<ul>
{actions.map((option, i: number) => {
if (option === null) {

View File

@@ -25,7 +25,7 @@ import { layoutFromWindow } from 'flavours/glitch/is_mobile';
import { selectUnreadNotificationGroupsCount } from 'flavours/glitch/selectors/notifications';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { checkAnnualReport } from '@/flavours/glitch/reducers/slices/annual_report';
import { isServerFeatureEnabled } from '@/flavours/glitch/utils/environment';
import { isClientFeatureEnabled, isServerFeatureEnabled } from '@/flavours/glitch/utils/environment';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache';
@@ -68,6 +68,7 @@ import {
ListEdit,
ListMembers,
Collections,
CollectionDetail,
CollectionsEditor,
Blocks,
DomainBlocks,
@@ -83,6 +84,7 @@ import {
TermsOfService,
AccountFeatured,
AccountAbout,
AccountEdit,
Quotes,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
@@ -240,6 +242,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
{isClientFeatureEnabled('profile_editing') && <WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />}
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
@@ -274,12 +278,12 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />
[
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />,
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} />,
<WrappedRoute path='/collections' component={Collections} content={children} />
]
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
<Route component={BundleColumnError} />
</WrappedSwitch>
</ColumnsAreaContainer>

View File

@@ -44,13 +44,19 @@ export function DirectTimeline() {
return import('../../direct_timeline');
}
export function Collections () {
export function Collections() {
return import('../../collections').then(
module => ({default: module.Collections})
);
}
export function CollectionsEditor () {
export function CollectionDetail() {
return import('../../collections/detail/index').then(
module => ({default: module.CollectionDetailPage})
);
}
export function CollectionsEditor() {
return import('../../collections/editor').then(
module => ({default: module.CollectionEditorPage})
);
@@ -92,6 +98,11 @@ export function AccountAbout() {
.then((module) => ({ default: module.AccountAbout }));
}
export function AccountEdit() {
return import('../../account_edit')
.then((module) => ({ default: module.AccountEdit }));
}
export function Followers () {
return import('../../followers');
}

View File

@@ -55,3 +55,7 @@ export function useAccountId() {
return accountId satisfies AccountId;
}
export function useCurrentAccountId() {
return useAppSelector((state) => state.meta.get('me', null) as string | null);
}

View File

@@ -14,7 +14,7 @@ import { CustomEmojiFactory } from './custom_emoji';
import type { CustomEmoji } from './custom_emoji';
// AccountField
interface AccountFieldShape extends Required<ApiAccountFieldJSON> {
export interface AccountFieldShape extends Required<ApiAccountFieldJSON> {
name_emojified: string;
value_emojified: string;
value_plain: string | null;

View File

@@ -4280,7 +4280,8 @@ input.glitch-setting-text {
cursor: pointer;
& > div {
background: rgb(from var(--color-shadow-primary) r g b / 60%);
color: var(--color-text-on-media);
background: rgb(from var(--color-bg-media) r g b / 60%);
border-radius: 8px;
padding: 12px 9px;
backdrop-filter: $backdrop-blur-filter;
@@ -4293,7 +4294,7 @@ input.glitch-setting-text {
button,
a {
display: inline;
color: var(--color-text-primary);
color: var(--color-text-on-media);
background: transparent;
border: 0;
padding: 0 8px;
@@ -4304,7 +4305,7 @@ input.glitch-setting-text {
&:hover,
&:active,
&:focus {
color: var(--color-text-primary);
color: var(--color-text-on-media);
}
}

View File

@@ -18,7 +18,7 @@ export function isServerFeatureEnabled(feature: ServerFeatures) {
return initialState?.features.includes(feature) ?? false;
}
type ClientFeatures = 'collections';
type ClientFeatures = 'collections' | 'profile_editing';
export function isClientFeatureEnabled(feature: ClientFeatures) {
try {

View File

@@ -2,9 +2,9 @@
<path fill="url(#VerifiedGradient)" d="M8 .837a3.168 3.168 0 0 1 2.47 1.187 3.166 3.166 0 0 1 2.601.906 3.168 3.168 0 0 1 .905 2.6A3.167 3.167 0 0 1 15.164 8a3.172 3.172 0 0 1-1.188 2.47 3.167 3.167 0 0 1-.903 2.597 3.168 3.168 0 0 1-2.596.909 3.167 3.167 0 0 1-4.95.001 3.166 3.166 0 0 1-3.397-2.258 3.169 3.169 0 0 1-.107-1.24A3.168 3.168 0 0 1 .826 8a3.17 3.17 0 0 1 1.197-2.479 3.168 3.168 0 0 1 .91-2.593 3.166 3.166 0 0 1 2.596-.905A3.169 3.169 0 0 1 8 .837Z"/>
<path stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.333" d="m6 8 1.333 1.333L10 6.667"/>
<defs>
<linearGradient id="VerifiedGradient" x1="-.966" x2="12.162" y1="2.629" y2="17.493" gradientUnits="userSpaceOnUse">
<stop offset=".13" stop-color="#5638CC"/>
<stop offset=".995" stop-color="#DC03F0"/>
<linearGradient id="VerifiedGradient" x1="7.99512" y1="0.836914" x2="7.99512" y2="15.1689" gradientUnits="userSpaceOnUse">
<stop stop-color="#00BC7D"/>
<stop offset="1" stop-color="#00BBA7"/>
</linearGradient>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 930 B

After

Width:  |  Height:  |  Size: 921 B

View File

@@ -11,14 +11,14 @@ export interface ApiCollectionJSON {
account_id: string;
id: string;
uri: string;
uri: string | null;
local: boolean;
item_count: number;
name: string;
description: string;
tag?: ApiTagJSON;
language: string;
tag: ApiTagJSON | null;
language: string | null;
sensitive: boolean;
discoverable: boolean;

View File

@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Avatar /> > Autoplay > renders a animated avatar 1`] = `
<div
<span
className="account__avatar account__avatar--loading"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
@@ -18,11 +18,11 @@ exports[`<Avatar /> > Autoplay > renders a animated avatar 1`] = `
onLoad={[Function]}
src="/animated/alice.gif"
/>
</div>
</span>
`;
exports[`<Avatar /> > Still > renders a still avatar 1`] = `
<div
<span
className="account__avatar account__avatar--loading"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
@@ -39,5 +39,5 @@ exports[`<Avatar /> > Still > renders a still avatar 1`] = `
onLoad={[Function]}
src="/static/alice.jpg"
/>
</div>
</span>
`;

View File

@@ -297,7 +297,7 @@ export const Account: React.FC<AccountProps> = ({
>
<div className='account__info-wrapper'>
<Link
className='account__display-name'
className='account__display-name focusable'
title={account?.acct}
to={`/@${account?.acct}`}
data-hover-card-account={id}

View File

@@ -53,7 +53,7 @@ export const Avatar: React.FC<Props> = ({
}, [setError]);
const avatar = (
<div
<span
className={classNames(className, 'account__avatar', {
'account__avatar--inline': inline,
'account__avatar--loading': loading,
@@ -67,14 +67,14 @@ export const Avatar: React.FC<Props> = ({
)}
{counter && (
<div
<span
className='account__avatar__counter'
style={{ borderColor: counterBorderColor }}
>
{counter}
</div>
</span>
)}
</div>
</span>
);
if (withLink) {

View File

@@ -1,6 +1,8 @@
import { forwardRef, useRef, useImperativeHandle } from 'react';
import type { Ref } from 'react';
import classNames from 'classnames';
import { scrollTop } from 'mastodon/scroll';
export interface ColumnRef {
@@ -12,10 +14,11 @@ interface ColumnProps {
children?: React.ReactNode;
label?: string;
bindToDocument?: boolean;
className?: string;
}
export const Column = forwardRef<ColumnRef, ColumnProps>(
({ children, label, bindToDocument }, ref: Ref<ColumnRef>) => {
({ children, label, bindToDocument, className }, ref: Ref<ColumnRef>) => {
const nodeRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
@@ -39,7 +42,12 @@ export const Column = forwardRef<ColumnRef, ColumnProps>(
}));
return (
<div role='region' aria-label={label} className='column' ref={nodeRef}>
<div
role='region'
aria-label={label}
className={classNames('column', className)}
ref={nodeRef}
>
{children}
</div>
);

View File

@@ -73,6 +73,7 @@ export interface Props {
iconComponent?: IconProp;
active?: boolean;
children?: React.ReactNode;
className?: string;
pinned?: boolean;
multiColumn?: boolean;
extraButton?: React.ReactNode;
@@ -91,6 +92,7 @@ export const ColumnHeader: React.FC<Props> = ({
iconComponent,
active,
children,
className,
pinned,
multiColumn,
extraButton,
@@ -141,7 +143,7 @@ export const ColumnHeader: React.FC<Props> = ({
onPin?.();
}, [history, pinned, onPin]);
const wrapperClassName = classNames('column-header__wrapper', {
const wrapperClassName = classNames('column-header__wrapper', className, {
active,
});
@@ -256,7 +258,8 @@ export const ColumnHeader: React.FC<Props> = ({
}
const hasIcon = icon && iconComponent;
const hasTitle = hasIcon && title;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const hasTitle = (hasIcon || backButton) && title;
const component = (
<div className={wrapperClassName}>
@@ -270,7 +273,7 @@ export const ColumnHeader: React.FC<Props> = ({
className='column-header__title'
type='button'
>
{!backButton && (
{!backButton && hasIcon && (
<Icon
id={icon}
icon={iconComponent}

View File

@@ -311,6 +311,7 @@ interface DropdownProps<Item extends object | null = MenuItem> {
status?: ImmutableMap<string, unknown>;
needsStatusRefresh?: boolean;
forceDropdown?: boolean;
className?: string;
renderItem?: RenderItemFn<Item>;
renderHeader?: RenderHeaderFn<Item>;
onOpen?: // Must use a union type for the full function as a union with void is not allowed.
@@ -335,6 +336,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
status,
needsStatusRefresh,
forceDropdown = false,
className,
renderItem,
renderHeader,
onOpen,
@@ -434,6 +436,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
modalProps: {
actions: items,
onClick: handleItemClick,
className,
},
}),
);
@@ -462,6 +465,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
handleClose,
statusId,
needsStatusRefresh,
className,
],
);
@@ -515,7 +519,7 @@ export const Dropdown = <Item extends object | null = MenuItem>({
popperConfig={popperConfig}
>
{({ props, arrowProps, placement }) => (
<div {...props} id={menuId}>
<div {...props} className={className} id={menuId}>
<div className={`dropdown-animation dropdown-menu ${placement}`}>
<div
className={`dropdown-menu__arrow ${placement}`}

View File

@@ -3,8 +3,10 @@ import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { useIdentity } from '@/mastodon/identity_context';
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
import {
fetchRelationships,
followAccount,
@@ -158,14 +160,24 @@ export const FollowButton: React.FC<{
}
if (accountId === me) {
const buttonClasses = classNames(className, 'button button-secondary', {
'button--compact': compact,
});
if (isClientFeatureEnabled('profile_editing')) {
return (
<Link to='/profile/edit' className={buttonClasses}>
{label}
</Link>
);
}
return (
<a
href='/settings/profile'
target='_blank'
rel='noopener'
className={classNames(className, 'button button-secondary', {
'button--compact': compact,
})}
className={buttonClasses}
>
{label}
</a>

View File

@@ -0,0 +1,53 @@
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Column } from '@/mastodon/components/column';
import { ColumnHeader } from '@/mastodon/components/column_header';
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
import classes from './styles.module.scss';
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
const accountId = useCurrentAccountId();
const account = useAccount(accountId);
const intl = useIntl();
if (!accountId) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
if (!account) {
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<LoadingIndicator />
</Column>
);
}
return (
<Column bindToDocument={!multiColumn} className={classes.column}>
<ColumnHeader
title={intl.formatMessage({
id: 'account_edit.column_title',
defaultMessage: 'Edit Profile',
})}
className={classes.header}
showBackButton
extraButton={
<Link to={`/@${account.acct}`} className='button'>
<FormattedMessage
id='account_edit.column_button'
defaultMessage='Done'
/>
</Link>
}
/>
</Column>
);
};

View File

@@ -0,0 +1,26 @@
.column {
border: 1px solid var(--color-border-primary);
border-top-width: 0;
}
.header {
:global(.column-header__buttons) {
align-items: center;
padding-inline-end: 16px;
height: auto;
}
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 24px 24px 12px;
> h1 {
flex-grow: 1;
font-weight: 600;
font-size: 15px;
}
}

View File

@@ -214,7 +214,10 @@ export const AccountHeader: React.FC<{
<>
<AccountBio
accountId={accountId}
className='account__header__content'
className={classNames(
'account__header__content',
isRedesign && redesignClasses.bio,
)}
/>
<AccountHeaderFields accountId={accountId} />
</>

View File

@@ -15,8 +15,7 @@ import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
import { Icon } from '@/mastodon/components/icon';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { Account } from '@/mastodon/models/account';
import { isValidUrl } from '@/mastodon/utils/checks';
import type { Account, AccountFieldShape } from '@/mastodon/models/account';
import type { OnElementHandler } from '@/mastodon/utils/html';
import { cleanExtraEmojis } from '../../emoji/normalize';
@@ -76,8 +75,8 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
[account.emojis],
);
const textHasCustomEmoji = useCallback(
(text: string) => {
if (!emojis) {
(text?: string | null) => {
if (!emojis || !text) {
return false;
}
for (const emoji of Object.keys(emojis)) {
@@ -92,62 +91,96 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink({
hashtagAccountId: account.id,
});
const intl = useIntl();
if (account.fields.isEmpty()) {
return null;
}
return (
<CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList}>
{account.fields.map(
(
{ name, name_emojified, value_emojified, value_plain, verified_at },
key,
) => (
<div
key={key}
className={classNames(
classes.fieldRow,
verified_at && classes.fieldVerified,
)}
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<FieldHTML
as='dd'
text={value_plain ?? ''}
textEmojified={value_emojified}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
titleLength={120}
{...htmlHandlers}
/>
{verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</div>
),
)}
{account.fields.map((field, key) => (
<FieldRow
key={key}
{...field.toJSON()}
htmlHandlers={htmlHandlers}
textHasCustomEmoji={textHasCustomEmoji}
/>
))}
</dl>
</CustomEmojiProvider>
);
};
const FieldRow: FC<
{
textHasCustomEmoji: (text?: string | null) => boolean;
htmlHandlers: ReturnType<typeof useElementHandledLink>;
} & AccountFieldShape
> = ({
textHasCustomEmoji,
htmlHandlers,
name,
name_emojified,
value_emojified,
value_plain,
verified_at,
}) => {
const intl = useIntl();
const [showAll, setShowAll] = useState(false);
const handleClick = useCallback(() => {
setShowAll((prev) => !prev);
}, []);
return (
/* eslint-disable -- This method of showing field contents is not very accessible, but it's what we've got for now */
<div
className={classNames(
classes.fieldRow,
verified_at && classes.fieldVerified,
showAll && classes.fieldShowAll,
)}
onClick={handleClick}
/* eslint-enable */
>
<FieldHTML
as='dt'
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={textHasCustomEmoji(name)}
titleLength={50}
className='translate'
{...htmlHandlers}
/>
<dd>
<FieldHTML
as='span'
text={value_plain ?? ''}
textEmojified={value_emojified}
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
titleLength={120}
{...htmlHandlers}
/>
{verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldVerifiedIcon}
aria-label={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
noFill
/>
)}
</dd>
</div>
);
};
const FieldHTML: FC<
{
as: 'dd' | 'dt';
as?: 'span' | 'dt';
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
@@ -164,11 +197,6 @@ const FieldHTML: FC<
onElement,
...props
}) => {
const [showAll, setShowAll] = useState(false);
const handleClick = useCallback(() => {
setShowAll((prev) => !prev);
}, []);
const handleElement: OnElementHandler = useCallback(
(element, props, children, extra) => {
if (element instanceof HTMLAnchorElement) {
@@ -186,17 +214,13 @@ const FieldHTML: FC<
},
[onElement, textHasCustomEmoji],
);
return (
<EmojiHTML
as={as}
htmlString={textEmojified}
title={showTitleOnLength(text, titleLength)}
className={classNames(
className,
text && isValidUrl(text) && classes.fieldLink,
showAll && classes.fieldShowAll,
)}
onClick={handleClick}
className={className}
onElement={handleElement}
{...props}
/>

View File

@@ -42,6 +42,8 @@ import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
@@ -86,6 +88,7 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
className={classes.buttonMenu}
/>
);
};

View File

@@ -24,11 +24,10 @@
.name {
flex-grow: 1;
font-size: 22px;
white-space: initial;
line-height: normal;
> h1 {
font-size: 22px;
line-height: normal;
white-space: initial;
}
}
@@ -149,11 +148,33 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
border-top: 1px solid var(--color-border-primary);
}
.buttonMenu {
// Override the modal for mobile.
&:global(.actions-modal) {
max-height: none;
}
li :global(.icon) {
width: 20px;
height: 20px;
}
}
.bio {
font-size: 15px;
}
.badge {
background-color: var(--color-bg-secondary);
border: none;
color: var(--color-text-secondary);
font-weight: 500;
padding: 4px;
font-size: 13px;
:global(.account__header__badges) > & {
line-height: 1;
}
> span {
font-weight: unset;
@@ -194,12 +215,13 @@ svg.badgeIcon {
.fieldList {
display: grid;
grid-template-columns: 160px 1fr min-content;
grid-template-columns: 160px 1fr;
column-gap: 12px;
margin: 4px 0 16px;
margin: 16px 0;
border-top: 0.5px solid var(--color-border-primary);
@container (width < 420px) {
grid-template-columns: 100px 1fr min-content;
grid-template-columns: 100px 1fr;
}
}
@@ -208,11 +230,10 @@ svg.badgeIcon {
grid-column: 1 / -1;
align-items: start;
grid-template-columns: subgrid;
padding: 0 4px;
padding: 8px;
border-bottom: 0.5px solid var(--color-border-primary);
> :is(dt, dd) {
margin: 8px 0;
&:not(.fieldShowAll) {
display: -webkit-box;
-webkit-box-orient: vertical;
@@ -227,43 +248,34 @@ svg.badgeIcon {
color: var(--color-text-secondary);
}
&:not(.fieldVerified) > dd {
grid-column: span 2;
> dd {
display: flex;
align-items: center;
gap: 4px;
}
a {
font-weight: 500;
color: var(--color-text-brand);
color: inherit;
text-decoration: none;
transition: 0.2s ease-in-out;
&:hover,
&:focus {
color: var(--color-text-brand-soft);
text-decoration: underline;
}
}
}
.fieldVerified {
background-color: var(--color-bg-brand-softer);
}
.fieldLink:is(dd, dt) {
margin: 0;
}
.fieldLink > a {
display: block;
padding: 8px 0;
background-color: var(--color-bg-success-softer);
}
.fieldVerifiedIcon {
width: 16px;
height: 16px;
margin-top: 8px;
}
.fieldNumbersWrapper {
font-size: 13px;
padding: 0;
a {
@@ -323,10 +335,15 @@ svg.badgeIcon {
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 12px;
padding: 0 12px;
padding: 0 24px;
@container (width >= 500px) {
padding: 0 24px;
@container (width < 500px) {
padding: 0 12px;
a {
flex: 1 1 0px;
text-align: center;
}
}
a {

View File

@@ -39,6 +39,7 @@
cursor: pointer;
display: flex;
align-items: center;
font-size: 15px;
}
}

View File

@@ -1,4 +1,4 @@
.collectionItemWrapper {
.wrapper {
display: flex;
align-items: center;
gap: 16px;
@@ -7,13 +7,13 @@
border-bottom: 1px solid var(--color-border-primary);
}
.collectionItemContent {
.content {
position: relative;
flex-grow: 1;
padding: 15px 5px;
}
.collectionItemLink {
.link {
display: block;
margin-bottom: 2px;
font-size: 15px;
@@ -33,16 +33,19 @@
}
}
.collectionItemInfo {
.info {
font-size: 13px;
color: var(--color-text-secondary);
}
.metaList {
--gap: 0.75ch;
display: flex;
gap: var(--gap);
font-size: 13px;
color: var(--color-text-secondary);
& > li:not(:first-child)::before {
& > li:not(:last-child)::after {
content: '·';
margin-inline-end: var(--gap);
margin-inline-start: var(--gap);
}
}

View File

@@ -0,0 +1,67 @@
import { useId } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import classes from './collection_list_item.module.scss';
import { CollectionMenu } from './collection_menu';
export const CollectionMetaData: React.FC<{
collection: ApiCollectionJSON;
className?: string;
}> = ({ collection, className }) => {
return (
<ul className={classNames(classes.metaList, className)}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
}}
tagName='li'
/>
</ul>
);
};
export const CollectionListItem: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const { id, name } = collection;
const linkId = useId();
return (
<article
className={classNames(classes.wrapper, 'focusable')}
tabIndex={-1}
aria-labelledby={linkId}
>
<div className={classes.content}>
<h2 id={linkId}>
<Link to={`/collections/${id}`} className={classes.link}>
{name}
</Link>
</h2>
<CollectionMetaData collection={collection} className={classes.info} />
</div>
<CollectionMenu context='list' collection={collection} />
</article>
);
};

View File

@@ -0,0 +1,91 @@
import { useCallback, useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react';
import { openModal } from 'mastodon/actions/modal';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { IconButton } from 'mastodon/components/icon_button';
import { useAppDispatch } from 'mastodon/store';
import { messages as editorMessages } from '../editor';
const messages = defineMessages({
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
export const CollectionMenu: React.FC<{
collection: ApiCollectionJSON;
context: 'list' | 'collection';
className?: string;
}> = ({ collection, context, className }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { id, name } = collection;
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(() => {
const commonItems = [
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
];
if (context === 'list') {
return [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
...commonItems,
];
} else {
return commonItems;
}
}, [intl, id, handleDeleteClick, context]);
return (
<Dropdown scrollKey='collections' items={menu}>
<IconButton
icon='menu-icon'
iconComponent={MoreVertIcon}
title={intl.formatMessage(messages.more)}
className={className}
/>
</Dropdown>
);
};

View File

@@ -0,0 +1,178 @@
import { useCallback, useEffect } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { showAlert } from 'mastodon/actions/alerts';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Account } from 'mastodon/components/account';
import { Avatar } from 'mastodon/components/avatar';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LinkedDisplayName } from 'mastodon/components/display_name';
import { IconButton } from 'mastodon/components/icon_button';
import ScrollableList from 'mastodon/components/scrollable_list';
import { Tag } from 'mastodon/components/tags/tag';
import { useAccount } from 'mastodon/hooks/useAccount';
import { me } from 'mastodon/initial_state';
import { fetchCollection } from 'mastodon/reducers/slices/collections';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { CollectionMetaData } from './collection_list_item';
import { CollectionMenu } from './collection_menu';
import classes from './styles.module.scss';
const messages = defineMessages({
empty: {
id: 'collections.accounts.empty_title',
defaultMessage: 'This collection is empty',
},
loading: {
id: 'collections.detail.loading',
defaultMessage: 'Loading collection…',
},
share: {
id: 'collections.detail.share',
defaultMessage: 'Share this collection',
},
accounts: {
id: 'collections.detail.accounts_heading',
defaultMessage: 'Accounts',
},
});
const AuthorNote: React.FC<{ id: string }> = ({ id }) => {
const account = useAccount(id);
const author = (
<span className={classes.displayNameWithAvatar}>
<Avatar size={18} account={account} />
<LinkedDisplayName displayProps={{ account, variant: 'simple' }} />
</span>
);
if (id === me) {
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_you'
defaultMessage='Curated by you'
/>
</p>
);
}
return (
<p className={classes.authorNote}>
<FormattedMessage
id='collections.detail.curated_by_author'
defaultMessage='Curated by {author}'
values={{ author }}
/>
</p>
);
};
const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({
collection,
}) => {
const intl = useIntl();
const { name, description, tag } = collection;
const dispatch = useAppDispatch();
const handleShare = useCallback(() => {
dispatch(showAlert({ message: 'Collection sharing not yet implemented' }));
}, [dispatch]);
return (
<div className={classes.header}>
<div className={classes.titleWithMenu}>
<div className={classes.titleWrapper}>
{tag && (
// TODO: Make non-interactive tag component
<Tag name={tag.name} className={classes.tag} />
)}
<h2 className={classes.name}>{name}</h2>
</div>
<div className={classes.headerButtonWrapper}>
<IconButton
iconComponent={ShareIcon}
icon='share-icon'
title={intl.formatMessage(messages.share)}
className={classes.iconButton}
onClick={handleShare}
/>
<CollectionMenu
context='collection'
collection={collection}
className={classes.iconButton}
/>
</div>
</div>
{description && <p className={classes.description}>{description}</p>}
<AuthorNote id={collection.account_id} />
<CollectionMetaData
collection={collection}
className={classes.metaData}
/>
<h2 className='sr-only'>{intl.formatMessage(messages.accounts)}</h2>
</div>
);
};
export const CollectionDetailPage: 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 isLoading = !!id && !collection;
useEffect(() => {
if (id) {
void dispatch(fetchCollection({ collectionId: id }));
}
}, [dispatch, id]);
const pageTitle = collection?.name ?? intl.formatMessage(messages.loading);
return (
<Column bindToDocument={!multiColumn} label={pageTitle}>
<ColumnHeader
showBackButton
title={pageTitle}
icon='collection-icon'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
/>
<ScrollableList
scrollKey='collection-detail'
emptyMessage={intl.formatMessage(messages.empty)}
showLoading={isLoading}
bindToDocument={!multiColumn}
alwaysPrepend
prepend={
collection ? <CollectionHeader collection={collection} /> : null
}
>
{collection?.items.map(({ account_id }) =>
account_id ? (
<Account key={account_id} minimal id={account_id} />
) : null,
)}
</ScrollableList>
<Helmet>
<title>{pageTitle}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};

View File

@@ -0,0 +1,74 @@
.header {
padding: 16px;
border-bottom: 1px solid var(--color-border-primary);
}
.titleWithMenu {
display: flex;
align-items: start;
gap: 10px;
}
.titleWrapper {
flex-grow: 1;
min-width: 0;
}
.tag {
margin-bottom: 4px;
margin-inline-start: -8px;
}
.name {
font-size: 28px;
line-height: 1.2;
overflow-wrap: anywhere;
}
.description {
font-size: 15px;
margin-top: 8px;
}
.headerButtonWrapper {
display: flex;
gap: 8px;
}
.iconButton {
box-sizing: content-box;
padding: 5px;
border-radius: 4px;
border: 1px solid var(--color-border-primary);
}
.authorNote {
margin-top: 8px;
font-size: 13px;
color: var(--color-text-secondary);
}
.metaData {
margin-top: 16px;
font-size: 15px;
}
.displayNameWithAvatar {
display: inline-flex;
gap: 4px;
align-items: baseline;
a {
color: inherit;
text-decoration: underline;
&:hover,
&:focus {
text-decoration: none;
}
}
> :global(.account__avatar) {
align-self: center;
}
}

View File

@@ -68,7 +68,7 @@ export const CollectionDetails: React.FC<{
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`);
history.push(`/collections/${id}`);
});
} else {
const payload: Partial<ApiCreateCollectionPayload> = {

View File

@@ -68,7 +68,7 @@ export const CollectionSettings: React.FC<{
};
void dispatch(updateCollection({ payload })).then(() => {
history.push(`/collections`);
history.push(`/collections/${id}`);
});
} else {
const payload: ApiCreateCollectionPayload = {

View File

@@ -1,22 +1,16 @@
import { useEffect, useMemo, useCallback, useId } from 'react';
import { useEffect } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
import { openModal } from 'mastodon/actions/modal';
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Dropdown } from 'mastodon/components/dropdown_menu';
import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import ScrollableList from 'mastodon/components/scrollable_list';
import {
fetchAccountCollections,
@@ -24,119 +18,13 @@ import {
} from 'mastodon/reducers/slices/collections';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { CollectionListItem } from './detail/collection_list_item';
import { messages as editorMessages } from './editor';
import classes from './styles.module.scss';
const messages = defineMessages({
heading: { id: 'column.collections', defaultMessage: 'My collections' },
view: {
id: 'collections.view_collection',
defaultMessage: 'View collection',
},
delete: {
id: 'collections.delete_collection',
defaultMessage: 'Delete collection',
},
more: { id: 'status.more', defaultMessage: 'More' },
});
const CollectionItem: React.FC<{
collection: ApiCollectionJSON;
}> = ({ collection }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const { id, name } = collection;
const handleDeleteClick = useCallback(() => {
dispatch(
openModal({
modalType: 'CONFIRM_DELETE_COLLECTION',
modalProps: {
name,
id,
},
}),
);
}, [dispatch, id, name]);
const menu = useMemo(
() => [
{ text: intl.formatMessage(messages.view), to: `/collections/${id}` },
null,
{
text: intl.formatMessage(editorMessages.manageAccounts),
to: `/collections/${id}/edit`,
},
{
text: intl.formatMessage(editorMessages.editDetails),
to: `/collections/${id}/edit/details`,
},
{
text: intl.formatMessage(editorMessages.editSettings),
to: `/collections/${id}/edit/settings`,
},
null,
{
text: intl.formatMessage(messages.delete),
action: handleDeleteClick,
dangerous: true,
},
],
[intl, id, handleDeleteClick],
);
const linkId = useId();
return (
<article
className={classNames(classes.collectionItemWrapper, 'focusable')}
tabIndex={-1}
aria-labelledby={linkId}
>
<div className={classes.collectionItemContent}>
<h2 id={linkId}>
<Link
to={`/collections/${id}/edit/details`}
className={classes.collectionItemLink}
>
{name}
</Link>
</h2>
<ul className={classes.collectionItemInfo}>
<FormattedMessage
id='collections.account_count'
defaultMessage='{count, plural, one {# account} other {# accounts}}'
values={{ count: collection.item_count }}
tagName='li'
/>
<FormattedMessage
id='collections.last_updated_at'
defaultMessage='Last updated: {date}'
values={{
date: (
<RelativeTimestamp
timestamp={collection.updated_at}
short={false}
/>
),
}}
tagName='li'
/>
</ul>
</div>
<Dropdown
scrollKey='collections'
items={menu}
icon='ellipsis-h'
iconComponent={MoreHorizIcon}
title={intl.formatMessage(messages.more)}
/>
</article>
);
};
export const Collections: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
@@ -202,7 +90,7 @@ export const Collections: React.FC<{
bindToDocument={!multiColumn}
>
{collections.map((item) => (
<CollectionItem key={item.id} collection={item} />
<CollectionListItem key={item.id} collection={item} />
))}
</ScrollableList>

View File

@@ -11,8 +11,9 @@ import {
export const ActionsModal: React.FC<{
actions: MenuItem[];
onClick: React.MouseEventHandler;
}> = ({ actions, onClick }) => (
<div className='modal-root__modal actions-modal'>
className?: string;
}> = ({ actions, onClick, className }) => (
<div className={classNames('modal-root__modal actions-modal', className)}>
<ul>
{actions.map((option, i: number) => {
if (option === null) {

View File

@@ -22,7 +22,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex
import { layoutFromWindow } from 'mastodon/is_mobile';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report';
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
import { isClientFeatureEnabled, isServerFeatureEnabled } from '@/mastodon/utils/environment';
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
import { clearHeight } from '../../actions/height_cache';
@@ -65,6 +65,7 @@ import {
ListEdit,
ListMembers,
Collections,
CollectionDetail,
CollectionsEditor,
Blocks,
DomainBlocks,
@@ -80,6 +81,7 @@ import {
TermsOfService,
AccountFeatured,
AccountAbout,
AccountEdit,
Quotes,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
@@ -232,6 +234,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
{isClientFeatureEnabled('profile_editing') && <WrappedRoute key="edit" path='/profile/edit' component={AccountEdit} content={children} />}
<WrappedRoute path={['/start', '/start/profile']} exact component={OnboardingProfile} content={children} />
<WrappedRoute path='/start/follows' component={OnboardingFollows} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
@@ -266,12 +270,12 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />
[
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />,
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} />,
<WrappedRoute path='/collections' component={Collections} content={children} />
]
}
{areCollectionsEnabled() &&
<WrappedRoute path='/collections' component={Collections} content={children} />
}
<Route component={BundleColumnError} />
</WrappedSwitch>
</ColumnsAreaContainer>

View File

@@ -44,13 +44,19 @@ export function Lists () {
return import('../../lists');
}
export function Collections () {
export function Collections() {
return import('../../collections').then(
module => ({default: module.Collections})
);
}
export function CollectionsEditor () {
export function CollectionDetail() {
return import('../../collections/detail/index').then(
module => ({default: module.CollectionDetailPage})
);
}
export function CollectionsEditor() {
return import('../../collections/editor').then(
module => ({default: module.CollectionEditorPage})
);
@@ -92,6 +98,11 @@ export function AccountAbout() {
.then((module) => ({ default: module.AccountAbout }));
}
export function AccountEdit() {
return import('../../account_edit')
.then((module) => ({ default: module.AccountEdit }));
}
export function Followers () {
return import('../../followers');
}

View File

@@ -55,3 +55,7 @@ export function useAccountId() {
return accountId satisfies AccountId;
}
export function useCurrentAccountId() {
return useAppSelector((state) => state.meta.get('me', null) as string | null);
}

View File

@@ -141,6 +141,8 @@
"account.unmute": "Не ігнараваць @{name}",
"account.unmute_notifications_short": "Апавяшчаць",
"account.unmute_short": "Не ігнараваць",
"account_edit.column_button": "Гатова",
"account_edit.column_title": "Рэдагаваць профіль",
"account_note.placeholder": "Націсніце, каб дадаць нататку",
"admin.dashboard.daily_retention": "Штодзённы паказчык утрымання карыстальнікаў пасля рэгістрацыі",
"admin.dashboard.monthly_retention": "Штомесячны паказчык утрымання карыстальнікаў пасля рэгістрацыі",

View File

@@ -13,6 +13,7 @@
"about.not_available": "Aquesta informació no és disponible en aquest servidor.",
"about.powered_by": "Xarxa social descentralitzada impulsada per {mastodon}",
"about.rules": "Normes del servidor",
"account.about": "Quant a",
"account.account_note_header": "Nota personal",
"account.activity": "Activitat",
"account.add_note": "Afegeix una nota personal",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Vis @{name} igen",
"account.unmute_notifications_short": "Vis notifikationer igen",
"account.unmute_short": "Vis igen",
"account_edit.column_button": "Færdig",
"account_edit.column_title": "Rediger profil",
"account_note.placeholder": "Klik for at tilføje notat",
"admin.dashboard.daily_retention": "Brugerfastholdelsesrate pr. dag efter tilmelding",
"admin.dashboard.monthly_retention": "Brugerfastholdelsesrate pr. måned efter tilmelding",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Stummschaltung von @{name} aufheben",
"account.unmute_notifications_short": "Stummschaltung der Benachrichtigungen aufheben",
"account.unmute_short": "Stummschaltung aufheben",
"account_edit.column_button": "Erledigt",
"account_edit.column_title": "Profil bearbeiten",
"account_note.placeholder": "Klicken, um private Anmerkung hinzuzufügen",
"admin.dashboard.daily_retention": "Verweildauer der Nutzer*innen pro Tag seit der Registrierung",
"admin.dashboard.monthly_retention": "Verweildauer der Nutzer*innen pro Monat seit der Registrierung",
@@ -264,7 +266,7 @@
"collections.edit_details": "Allgemeine Informationen bearbeiten",
"collections.edit_settings": "Einstellungen bearbeiten",
"collections.error_loading_collections": "Beim Laden deiner Sammlungen ist ein Fehler aufgetreten.",
"collections.hints.accounts_counter": "{count} / {max} Konten",
"collections.hints.accounts_counter": "{count}/{max} Konten",
"collections.hints.add_more_accounts": "Füge mindestens {count, plural, one {# Konto} other {# Konten}} hinzu, um fortzufahren",
"collections.hints.can_not_remove_more_accounts": "Sammlungen müssen mindestens {count, plural, one {# Konto} other {# Konten}} enthalten. Weitere Konten zu entfernen, ist daher nicht erlaubt.",
"collections.last_updated_at": "Aktualisiert: {date}",
@@ -276,7 +278,7 @@
"collections.new_collection": "Neue Sammlung",
"collections.no_collections_yet": "Bisher keine Sammlungen vorhanden.",
"collections.remove_account": "Dieses Konto entfernen",
"collections.search_accounts_label": "Konten suchen, um sie hinzuzufügen …",
"collections.search_accounts_label": "Suche nach Konten, um sie hinzuzufügen …",
"collections.search_accounts_max_reached": "Du hast die Höchstzahl an Konten hinzugefügt",
"collections.topic_hint": "Ein Hashtag für diese Sammlung kann anderen dabei helfen, dein Anliegen besser einordnen zu können.",
"collections.view_collection": "Sammlungen anzeigen",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Άρση σίγασης @{name}",
"account.unmute_notifications_short": "Σίγαση ειδοποιήσεων",
"account.unmute_short": "Κατάργηση σίγασης",
"account_edit.column_button": "Έγινε",
"account_edit.column_title": "Επεξεργασία Προφίλ",
"account_note.placeholder": "Κάνε κλικ για να προσθέσεις σημείωση",
"admin.dashboard.daily_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά ημέρα",
"admin.dashboard.monthly_retention": "Ποσοστό χρηστών που παραμένουν μετά την εγγραφή, ανά μήνα",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Unmute @{name}",
"account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute",
"account_edit.column_button": "Done",
"account_edit.column_title": "Edit Profile",
"account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Unmute @{name}",
"account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute",
"account_edit.column_button": "Done",
"account_edit.column_title": "Edit Profile",
"account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
@@ -261,6 +263,11 @@
"collections.create_collection": "Create collection",
"collections.delete_collection": "Delete collection",
"collections.description_length_hint": "100 characters limit",
"collections.detail.accounts_heading": "Accounts",
"collections.detail.curated_by_author": "Curated by {author}",
"collections.detail.curated_by_you": "Curated by you",
"collections.detail.loading": "Loading collection…",
"collections.detail.share": "Share this collection",
"collections.edit_details": "Edit basic details",
"collections.edit_settings": "Edit settings",
"collections.error_loading_collections": "There was an error when trying to load your collections.",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Dejar de silenciar a @{name}",
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
"account.unmute_short": "Dejar de silenciar",
"account_edit.column_button": "Listo",
"account_edit.column_title": "Editar perfil",
"account_note.placeholder": "Hacé clic par agregar una nota",
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día, después del registro",
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes, después del registro",

View File

@@ -72,11 +72,11 @@
"account.go_to_profile": "Ir al perfil",
"account.hide_reblogs": "Ocultar impulsos de @{name}",
"account.in_memoriam": "En memoria.",
"account.joined_long": "Se unión el {date}",
"account.joined_long": "Se unió el {date}",
"account.joined_short": "Se unió",
"account.languages": "Cambiar idiomas suscritos",
"account.link_verified_on": "El proprietario de este enlace fue comprobado el {date}",
"account.locked_info": "El estado de privacidad de esta cuenta està configurado como bloqueado. El proprietario debe revisar manualmente quien puede seguirle.",
"account.link_verified_on": "Se verificó la propiedad de este enlace el {date}",
"account.locked_info": "El estado de privacidad de esta cuenta está configurado como bloqueado. El propietario revisa manualmente quién puede seguirlo.",
"account.media": "Multimedia",
"account.mention": "Mencionar a @{name}",
"account.menu.add_to_list": "Añadir a lista…",
@@ -141,6 +141,8 @@
"account.unmute": "Dejar de silenciar a @{name}",
"account.unmute_notifications_short": "Dejar de silenciar notificaciones",
"account.unmute_short": "Dejar de silenciar",
"account_edit.column_button": "Hecho",
"account_edit.column_title": "Editar perfil",
"account_note.placeholder": "Haz clic para agregar una nota",
"admin.dashboard.daily_retention": "Tasa de retención de usuarios por día después de unirse",
"admin.dashboard.monthly_retention": "Tasa de retención de usuarios por mes después de unirse",
@@ -296,9 +298,9 @@
"column.domain_blocks": "Dominios ocultados",
"column.edit_list": "Editar lista",
"column.favourites": "Favoritos",
"column.firehose": "Feeds en vivo",
"column.firehose_local": "Feed en vivo para este servidor",
"column.firehose_singular": "Feed en vivo",
"column.firehose": "Cronologías en vivo",
"column.firehose_local": "Cronología en vivo para este servidor",
"column.firehose_singular": "Cronología en vivo",
"column.follow_requests": "Solicitudes de seguimiento",
"column.home": "Inicio",
"column.list_members": "Administrar miembros de la lista",
@@ -313,7 +315,7 @@
"column_header.moveRight_settings": "Mover columna a la derecha",
"column_header.pin": "Fijar",
"column_header.show_settings": "Mostrar ajustes",
"column_header.unpin": "Desfijar",
"column_header.unpin": "Dejar de fijar",
"column_search.cancel": "Cancelar",
"combobox.close_results": "Cerrar resultados",
"combobox.loading": "Cargando",
@@ -431,7 +433,7 @@
"domain_block_modal.you_will_lose_num_followers": "Vas a perder {followersCount, plural, one {{followersCountDisplay} seguidor} other {{followersCountDisplay} seguidores}} y {followingCount, plural, one {{followingCountDisplay} persona a la que sigues} other {{followingCountDisplay} personas a las que sigas}}.",
"domain_block_modal.you_will_lose_relationships": "Perderás todos los seguidores y las personas que sigues de este servidor.",
"domain_block_modal.you_wont_see_posts": "No verás publicaciones ni notificaciones de usuarios en este servidor.",
"domain_pill.activitypub_lets_connect": "Te permite conectar e interactuar con personas no sólo en Mastodon, sino también a través de diferentes aplicaciones sociales.",
"domain_pill.activitypub_lets_connect": "Te permite conectarte e interactuar con personas no solo en Mastodon, sino también en diferentes aplicaciones sociales.",
"domain_pill.activitypub_like_language": "ActivityPub es como el idioma que Mastodon habla con otras redes sociales.",
"domain_pill.server": "Servidor",
"domain_pill.their_handle": "Su alias:",
@@ -475,7 +477,7 @@
"empty_column.bookmarked_statuses": "Aún no tienes ninguna publicación guardada como marcador. Cuando guardes una, se mostrará aquí.",
"empty_column.community": "La cronología local está vacía. ¡Escribe algo públicamente para ponerla en marcha!",
"empty_column.direct": "Aún no tienes ninguna mención privada. Cuando envíes o recibas una, aparecerá aquí.",
"empty_column.disabled_feed": "Este feed fue desactivado por los administradores de tu servidor.",
"empty_column.disabled_feed": "Esta cronología fue desactivada por los administradores de tu servidor.",
"empty_column.domain_blocks": "Todavía no hay dominios ocultos.",
"empty_column.explore_statuses": "Nada es tendencia en este momento. ¡Revisa más tarde!",
"empty_column.favourited_statuses": "Todavía no tienes publicaciones favoritas. Cuando le des favorito a una publicación se mostrarán acá.",
@@ -506,9 +508,9 @@
"featured_carousel.header": "{count, plural,one {Publicación fijada}other {Publicaciones fijadas}}",
"featured_carousel.slide": "Publicación {current, number} de {max, number}",
"featured_tags.more_items": "+{count}",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que has accedido a esta publlicación. Si quieres que la publicación sea filtrada también en este contexto, tendrás que editar el filtro.",
"filter_modal.added.context_mismatch_explanation": "Esta categoría de filtro no se aplica al contexto en el que has accedido a esta publicación. Si deseas que la publicación también se filtre en este contexto, tendrás que editar el filtro.",
"filter_modal.added.context_mismatch_title": "¡El contexto no coincide!",
"filter_modal.added.expired_explanation": "Esta categoría de filtro ha caducado, necesitaras cambiar la fecha de caducidad para que se aplique.",
"filter_modal.added.expired_explanation": "Esta categoría de filtro ha caducado; deberás cambiar la fecha de caducidad para que se aplique.",
"filter_modal.added.expired_title": "¡Filtro expirado!",
"filter_modal.added.review_and_configure": "Para revisar y configurar esta categoría de filtros, vaya a {settings_link}.",
"filter_modal.added.review_and_configure_title": "Ajustes de filtro",
@@ -746,7 +748,7 @@
"notification.admin.report_account_other": "{name} reportó {count, plural, one {una publicación} other {# publicaciones}} de {target}",
"notification.admin.report_statuses": "{name} reportó {target} por {category}",
"notification.admin.report_statuses_other": "{name} reportó {target}",
"notification.admin.sign_up": "{name} se unio",
"notification.admin.sign_up": "{name} se registró",
"notification.admin.sign_up.name_and_others": "{name} y {count, plural, one {# otro} other {# otros}} se registraron",
"notification.annual_report.message": "¡Tu #Wrapstodon {year} te espera! ¡Desvela los momentos más destacados y memorables de tu año en Mastodon!",
"notification.annual_report.view": "Ver #Wrapstodon",
@@ -866,17 +868,17 @@
"onboarding.follows.empty": "Desafortunadamente, no se pueden mostrar resultados en este momento. Puedes intentar usar la búsqueda o navegar por la página de exploración para encontrar gente a la que seguir, o inténtalo de nuevo más tarde.",
"onboarding.follows.search": "Buscar",
"onboarding.follows.title": "Sigue personas para comenzar",
"onboarding.profile.discoverable": "Make my profile discoverable",
"onboarding.profile.discoverable_hint": "Cuando aceptas ser descubierto en Mastodon, tus publicaciones pueden aparecer en resultados de búsqueda y tendencias, y tu perfil puede ser sugerido a personas con intereses similares a los tuyos.",
"onboarding.profile.display_name": "Nombre a mostrar",
"onboarding.profile.discoverable": "Hacer que mi perfil aparezca en búsquedas",
"onboarding.profile.discoverable_hint": "Cuando permites que tu perfil aparezca en búsquedas en Mastodon, tus publicaciones pueden aparecer en los resultados de búsqueda y en las tendencias, y tu perfil puede ser sugerido a personas con intereses similares a los tuyos.",
"onboarding.profile.display_name": "Nombre para mostrar",
"onboarding.profile.display_name_hint": "Tu nombre completo o tu apodo…",
"onboarding.profile.note": "Biografía",
"onboarding.profile.note_hint": "Puedes @mencionar a otras personas o #etiquetas…",
"onboarding.profile.save_and_continue": "Guardar y continuar",
"onboarding.profile.title": "Configuración del perfil",
"onboarding.profile.upload_avatar": "Subir foto de perfil",
"onboarding.profile.upload_header": "Subir foto de cabecera",
"password_confirmation.exceeds_maxlength": "La contraseña de confirmación excede la longitud máxima de la contraseña",
"onboarding.profile.upload_header": "Subir encabezado de perfil",
"password_confirmation.exceeds_maxlength": "La contraseña de confirmación supera la longitud máxima permitida",
"password_confirmation.mismatching": "La contraseña de confirmación no coincide",
"picture_in_picture.restore": "Restaurar",
"poll.closed": "Cerrada",
@@ -892,14 +894,14 @@
"privacy.change": "Ajustar privacidad",
"privacy.direct.long": "Todos los mencionados en la publicación",
"privacy.direct.short": "Mención privada",
"privacy.private.long": "Sólo tus seguidores",
"privacy.private.long": "Solo tus seguidores",
"privacy.private.short": "Seguidores",
"privacy.public.long": "Cualquiera dentro y fuera de Mastodon",
"privacy.public.short": "Público",
"privacy.quote.anyone": "{visibility}, citas permitidas",
"privacy.quote.disabled": "{visibility}, citas desactivadas",
"privacy.quote.limited": "{visibility}, citas limitadas",
"privacy.unlisted.additional": "Esto se comporta exactamente igual que el público, excepto que el post no aparecerá en las cronologías en directo o en las etiquetas, la exploración o busquedas en Mastodon, incluso si está optado por activar la cuenta de usuario.",
"privacy.unlisted.additional": "Esto funciona exactamente igual que «público», excepto que la publicación no aparecerá en las transmisiones en vivo ni en las etiquetas, en «explorar» ni en la búsqueda de Mastodon, incluso si has optado por ello en toda tu cuenta.",
"privacy.unlisted.long": "Oculto de los resultados de búsquedas, tendencias y cronologías públicas de Mastodon",
"privacy.unlisted.short": "Pública, pero discreta",
"privacy_policy.last_updated": "Actualizado por última vez {date}",
@@ -917,7 +919,7 @@
"relative_time.days": "{number} d",
"relative_time.full.days": "{number, plural, one {# día} other {# días hace}}",
"relative_time.full.hours": "{number, plural, one {# hora} other {# horas}} hace",
"relative_time.full.just_now": "justo ahora",
"relative_time.full.just_now": "ahora mismo",
"relative_time.full.minutes": "Hace {number, plural, one {# minute} other {# minutos}}",
"relative_time.full.seconds": "Hace {number, plural, one {# second} other {# segundos}}",
"relative_time.hours": "{number} h",
@@ -932,7 +934,7 @@
"reply_indicator.cancel": "Cancelar",
"reply_indicator.poll": "Encuesta",
"report.block": "Bloquear",
"report.block_explanation": "No veras sus publicaciones. No podrán ver tus publicaciones ni seguirte. Podrán saber que están bloqueados.",
"report.block_explanation": "No verás sus publicaciones. Ellos no podrán ver tus publicaciones ni seguirte. Podrán saber que están bloqueados.",
"report.categories.legal": "Legal",
"report.categories.other": "Otro",
"report.categories.spam": "Spam",
@@ -944,7 +946,7 @@
"report.close": "Realizado",
"report.comment.title": "¿Hay algo más que creas que deberíamos saber?",
"report.forward": "Reenviar a {target}",
"report.forward_hint": "Esta cuenta es de otro servidor. ¿Enviar una copia anonimizada del informe allí también?",
"report.forward_hint": "La cuenta es de otro servidor. ¿Enviar también una copia anónima del informe allí?",
"report.mute": "Silenciar",
"report.mute_explanation": "No verás sus publicaciones. Todavía pueden seguirte y ver tus publicaciones y no sabrán que están silenciados.",
"report.next": "Siguiente",
@@ -970,7 +972,7 @@
"report.thanks.title": "¿No quieres ver esto?",
"report.thanks.title_actionable": "Gracias por denunciar, revisaremos esto.",
"report.unfollow": "Dejar de seguir @{name}",
"report.unfollow_explanation": "Estás siguiendo esta cuenta. Para no ver sus publicaciones en tu inicio, deja de seguirla.",
"report.unfollow_explanation": "Estás siguiendo esta cuenta. Para dejar de ver sus publicaciones en tu página de inicio, deja de seguirla.",
"report_notification.attached_statuses": "{count, plural, one {{count} publicación} other {{count} publicaciones}} adjunta(s)",
"report_notification.categories.legal": "Legal",
"report_notification.categories.legal_sentence": "contenido ilegal",
@@ -991,7 +993,7 @@
"search.quick_action.status_search": "Publicaciones que coinciden con {x}",
"search.search_or_paste": "Buscar o pegar URL",
"search_popout.full_text_search_disabled_message": "No disponible en {domain}.",
"search_popout.full_text_search_logged_out_message": "Sólo disponible al iniciar sesión.",
"search_popout.full_text_search_logged_out_message": "Solo disponible al iniciar sesión.",
"search_popout.language_code": "Código de idioma ISO",
"search_popout.options": "Opciones de búsqueda",
"search_popout.quick_actions": "Acciones rápidas",
@@ -1049,8 +1051,8 @@
"status.history.created": "{name} creó {date}",
"status.history.edited": "{name} editado {date}",
"status.load_more": "Cargar más",
"status.media.open": "Click para abrir",
"status.media.show": "Click para mostrar",
"status.media.open": "Haz clic para abrir",
"status.media.show": "Haz clic para mostrar",
"status.media_hidden": "Contenido multimedia oculto",
"status.mention": "Mencionar @{name}",
"status.more": "Más",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Kumoa käyttäjän @{name} mykistys",
"account.unmute_notifications_short": "Kumoa ilmoitusten mykistys",
"account.unmute_short": "Kumoa mykistys",
"account_edit.column_button": "Valmis",
"account_edit.column_title": "Muokkaa profiilia",
"account_note.placeholder": "Lisää muistiinpano napsauttamalla",
"admin.dashboard.daily_retention": "Käyttäjien pysyvyys päivittäin rekisteröitymisen jälkeen",
"admin.dashboard.monthly_retention": "Käyttäjien pysyvyys kuukausittain rekisteröitymisen jälkeen",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Doyv ikki @{name}",
"account.unmute_notifications_short": "Tendra fráboðanir",
"account.unmute_short": "Doyv ikki",
"account_edit.column_button": "Liðugt",
"account_edit.column_title": "Rætta vanga",
"account_note.placeholder": "Klikka fyri at leggja viðmerking afturat",
"admin.dashboard.daily_retention": "Hvussu nógvir brúkarar eru eftir, síðani tey skrásettu seg, roknað í døgum",
"admin.dashboard.monthly_retention": "Hvussu nógvir brúkarar eru eftir síðani tey skrásettu seg, roknað í mánaðum",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Deixar de silenciar a @{name}",
"account.unmute_notifications_short": "Reactivar notificacións",
"account.unmute_short": "Non silenciar",
"account_edit.column_button": "Feito",
"account_edit.column_title": "Editar perfil",
"account_note.placeholder": "Preme para engadir nota",
"admin.dashboard.daily_retention": "Ratio de retención de usuarias diaria após rexistrarse",
"admin.dashboard.monthly_retention": "Ratio de retención de usuarias mensual após o rexistro",
@@ -244,9 +246,12 @@
"closed_registrations_modal.preamble": "Mastodon é descentralizado, así que non importa onde crees a conta, poderás seguir e interactuar con calquera conta deste servidor. Incluso podes ter o teu servidor!",
"closed_registrations_modal.title": "Crear conta en Mastodon",
"collections.account_count": "{count, plural, one {# conta} other {# contas}}",
"collections.accounts.empty_description": "Engade ata {count} contas que segues",
"collections.accounts.empty_title": "A colección está baleira",
"collections.collection_description": "Descrición",
"collections.collection_name": "Nome",
"collections.collection_topic": "Temática",
"collections.confirm_account_removal": "Tes certeza de querer retirar esta conta desta colección?",
"collections.content_warning": "Aviso sobre o contido",
"collections.continue": "Continuar",
"collections.create.accounts_subtitle": "Só se poden engadir contas que segues e que optaron por ser incluídas en descubrir.",
@@ -261,6 +266,9 @@
"collections.edit_details": "Editar detalles básicos",
"collections.edit_settings": "Editar axustes",
"collections.error_loading_collections": "Houbo un erro ao intentar cargar as túas coleccións.",
"collections.hints.accounts_counter": "{count} / {max} contas",
"collections.hints.add_more_accounts": "Engade polo menos {count, plural, one {# conta} other {# contas}} para continuar",
"collections.hints.can_not_remove_more_accounts": "As coleccións teñen que conter polo menos {count, plural, one {# conta} other {# contas}}. Non é posible retirar máis contas.",
"collections.last_updated_at": "Última actualización: {date}",
"collections.manage_accounts": "Xestionar contas",
"collections.manage_accounts_in_collection": "Xestionar as contas nesta colección",
@@ -269,6 +277,9 @@
"collections.name_length_hint": "Límite de 100 caracteres",
"collections.new_collection": "Nova colección",
"collections.no_collections_yet": "Aínda non tes coleccións.",
"collections.remove_account": "Retirar esta conta",
"collections.search_accounts_label": "Buscar contas para engadir…",
"collections.search_accounts_max_reached": "Acadaches o máximo de contas permitidas",
"collections.topic_hint": "Engadir un cancelo para que axudar a que outras persoas coñezan a temática desta colección.",
"collections.view_collection": "Ver colección",
"collections.visibility_public": "Pública",

View File

@@ -141,6 +141,8 @@
"account.unmute": "הפסקת השתקת @{name}",
"account.unmute_notifications_short": "הפעלת הודעות",
"account.unmute_short": "ביטול השתקה",
"account_edit.column_button": "סיום",
"account_edit.column_title": "עריכת הפרופיל",
"account_note.placeholder": "יש ללחוץ כדי להוסיף הערות",
"admin.dashboard.daily_retention": "קצב שימור משתמשים יומי אחרי ההרשמה",
"admin.dashboard.monthly_retention": "קצב שימור משתמשים (פר חודש) אחרי ההרשמה",
@@ -244,9 +246,12 @@
"closed_registrations_modal.preamble": "מסטודון הוא רשת מבוזרת, כך שלא משנה היכן החשבון שלך, קיימת האפשרות לעקוב ולתקשר עם משתמשים בשרת הזה. אפשר אפילו להריץ שרת בעצמך!",
"closed_registrations_modal.title": "להרשם למסטודון",
"collections.account_count": "{count, plural, one {חשבון אחד} other {# חשבונות}}",
"collections.accounts.empty_description": "להוסיף עד ל־{count} חשבונות שאתם עוקבים אחריהם",
"collections.accounts.empty_title": "האוסף הזה ריק",
"collections.collection_description": "תיאור",
"collections.collection_name": "כינוי",
"collections.collection_topic": "נושא",
"collections.confirm_account_removal": "בוודאות להסיר חשבון זה מהאוסף?",
"collections.content_warning": "אזהרת תוכן",
"collections.continue": "המשך",
"collections.create.accounts_subtitle": "רק חשבונות נעקבים שבחרו להופיע ב\"תגליות\" ניתנים להוספה.",
@@ -261,6 +266,9 @@
"collections.edit_details": "עריכת פרטים בסיסיים",
"collections.edit_settings": "עריכת הגדרות",
"collections.error_loading_collections": "חלה שגיאה בנסיון לטעון את אוספיך.",
"collections.hints.accounts_counter": "{count} \\ {max} חשבונות",
"collections.hints.add_more_accounts": "הוסיפו לפחות {count, plural,one {חשבון אחד}other {# חשבונות}} כדי להמשיך",
"collections.hints.can_not_remove_more_accounts": "אוספים חייבים להכיל לפחות {count, plural,one {חשבון אחד}other {# חשבונות}}. הסרת חשבונות נוספים איננה אפשרית.",
"collections.last_updated_at": "עדכון אחרון: {date}",
"collections.manage_accounts": "ניהול חשבונות",
"collections.manage_accounts_in_collection": "ניהול החשבונות שבאוסף זה",
@@ -269,6 +277,9 @@
"collections.name_length_hint": "מגבלה של 100 תווים",
"collections.new_collection": "אוסף חדש",
"collections.no_collections_yet": "עוד אין אוספים.",
"collections.remove_account": "הסר חשבון זה",
"collections.search_accounts_label": "לחפש חשבונות להוספה…",
"collections.search_accounts_max_reached": "הגעת למספר החשבונות המירבי",
"collections.topic_hint": "הוספת תגית שמסייעת לאחרים להבין את הנושא הראשי של האוסף.",
"collections.view_collection": "צפיה באוסף",
"collections.visibility_public": "פומבי",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Hætta að þagga niður í @{name}",
"account.unmute_notifications_short": "Hætta að þagga í tilkynningum",
"account.unmute_short": "Hætta að þagga niður",
"account_edit.column_button": "Lokið",
"account_edit.column_title": "Breyta notandasniði",
"account_note.placeholder": "Smelltu til að bæta við minnispunkti",
"admin.dashboard.daily_retention": "Hlutfall virkra notenda eftir nýskráningu eftir dögum",
"admin.dashboard.monthly_retention": "Hlutfall virkra notenda eftir nýskráningu eftir mánuðum",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Riattiva @{name}",
"account.unmute_notifications_short": "Riattiva notifiche",
"account.unmute_short": "Attiva audio",
"account_edit.column_button": "Fatto",
"account_edit.column_title": "Modifica il profilo",
"account_note.placeholder": "Clicca per aggiungere una nota",
"admin.dashboard.daily_retention": "Tasso di ritenzione dell'utente per giorno, dopo la registrazione",
"admin.dashboard.monthly_retention": "Tasso di ritenzione dell'utente per mese, dopo la registrazione",
@@ -244,9 +246,12 @@
"closed_registrations_modal.preamble": "Mastodon è decentralizzato, quindi, non importa dove crei il tuo profilo, potrai seguire e interagire con chiunque su questo server. Anche se sei tu stesso a ospitarlo!",
"closed_registrations_modal.title": "Registrazione su Mastodon",
"collections.account_count": "{count, plural, one {# account} other {# account}}",
"collections.accounts.empty_description": "Aggiungi fino a {count} account che segui",
"collections.accounts.empty_title": "Questa collezione è vuota",
"collections.collection_description": "Descrizione",
"collections.collection_name": "Nome",
"collections.collection_topic": "Argomento",
"collections.confirm_account_removal": "Si è sicuri di voler rimuovere questo account da questa collezione?",
"collections.content_warning": "Avviso sul contenuto",
"collections.continue": "Continua",
"collections.create.accounts_subtitle": "Possono essere aggiunti solo gli account che segui e che hanno aderito alla funzione di scoperta.",
@@ -261,6 +266,9 @@
"collections.edit_details": "Modifica i dettagli di base",
"collections.edit_settings": "Modifica impostazioni",
"collections.error_loading_collections": "Si è verificato un errore durante il tentativo di caricare le tue collezioni.",
"collections.hints.accounts_counter": "{count} / {max} account",
"collections.hints.add_more_accounts": "Aggiungi almeno {count, plural, one {# account} other {# account}} per continuare",
"collections.hints.can_not_remove_more_accounts": "Le collezioni devono contenere almeno {count, plural, one {# account} other {# account}}. La rimozione di altri account, non è possibile.",
"collections.last_updated_at": "Ultimo aggiornamento: {date}",
"collections.manage_accounts": "Gestisci account",
"collections.manage_accounts_in_collection": "Gestisci gli account in questa collezione",
@@ -269,6 +277,9 @@
"collections.name_length_hint": "Limite di 100 caratteri",
"collections.new_collection": "Nuova collezione",
"collections.no_collections_yet": "Nessuna collezione ancora.",
"collections.remove_account": "Rimuovi questo account",
"collections.search_accounts_label": "Cerca account da aggiungere…",
"collections.search_accounts_max_reached": "Hai aggiunto il numero massimo di account",
"collections.topic_hint": "Aggiungi un hashtag che aiuti gli altri a comprendere l'argomento principale di questa collezione.",
"collections.view_collection": "Visualizza la collezione",
"collections.visibility_public": "Pubblica",

View File

@@ -13,6 +13,7 @@
"about.not_available": "Denne informasjonen er ikkje gjort tilgjengeleg på denne tenaren.",
"about.powered_by": "Desentraliserte sosiale medium drive av {mastodon}",
"about.rules": "Tenarreglar",
"account.about": "Om",
"account.account_note_header": "Personleg notat",
"account.activity": "Aktivitet",
"account.add_note": "Legg til eit personleg notat",
@@ -87,6 +88,7 @@
"account.menu.hide_reblogs": "Gøym framhevingar på tidslina",
"account.menu.mention": "Omtale",
"account.menu.mute": "Demp konto",
"account.menu.note.description": "Berre synleg for deg",
"account.menu.open_original_page": "Vis på {domain}",
"account.menu.remove_follower": "Fjern fylgjar",
"account.menu.report": "Rapporter kontoen",
@@ -102,6 +104,13 @@
"account.muted": "Målbunden",
"account.muting": "Dempa",
"account.mutual": "De fylgjer kvarandre",
"account.name.help.domain": "{domain} er tenaren som lagrar brukarprofilen og innlegga.",
"account.name.help.domain_self": "{domain} er tenaren som lagrar brukarprofilen og innlegga dine.",
"account.name.help.footer": "På same måten som du kan senda epost til folk med ulike epostprogram og -kontoar, kan du kommunisera med folk på andre Mastodon-tenarar, og med folk på andre sosiale nettverk som samhandlar på same måte som Mastodon. Det er ActivityPub-protokollen.",
"account.name.help.header": "Ei brukaradresse er som ei epostadresse",
"account.name.help.username": "{username} er brukarnamnet til denne kontoen på tenaren deira. Folk på andre tenarar kan ha same brukarnamnet.",
"account.name.help.username_self": "{username} er brukarnamnet ditt på denne tenaren. Folk på andre tenarar kan ha same brukarnamnet.",
"account.name_info": "Kva tyder dette?",
"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",
@@ -234,9 +243,13 @@
"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.account_count": "{count, plural, one {# konto} other {# kontoar}}",
"collections.accounts.empty_description": "Legg til opp til {count} kontoar du fylgjer",
"collections.accounts.empty_title": "Denne samlinga er tom",
"collections.collection_description": "Skildring",
"collections.collection_name": "Namn",
"collections.collection_topic": "Emne",
"collections.confirm_account_removal": "Er du sikker på at du vil fjerna denne brukarkontoen frå samlinga?",
"collections.content_warning": "Innhaldsåtvaring",
"collections.continue": "Hald fram",
"collections.create.accounts_subtitle": "Du kan berre leggja til kontoar du fylgjer og som har sagt ja til å bli oppdaga.",
@@ -251,6 +264,10 @@
"collections.edit_details": "Rediger grunnleggjande opplysingar",
"collections.edit_settings": "Rediger innstillingar",
"collections.error_loading_collections": "Noko gjekk gale då me prøvde å henta samlingane dine.",
"collections.hints.accounts_counter": "{count} av {max} kontoar",
"collections.hints.add_more_accounts": "Legg til minst {count, plural, one {# konto} other {# kontoar}} for å halda fram",
"collections.hints.can_not_remove_more_accounts": "Samlingar må innehalda minst {count, plural, one {# konto} other {# kontoar}}. Du kan ikkje fjerna fleire kontoar.",
"collections.last_updated_at": "Sist oppdatert: {date}",
"collections.manage_accounts": "Handter kontoar",
"collections.manage_accounts_in_collection": "Handter kontoar i denne samlinga",
"collections.mark_as_sensitive": "Merk som ømtolig",
@@ -258,6 +275,9 @@
"collections.name_length_hint": "Maks 100 teikn",
"collections.new_collection": "Ny samling",
"collections.no_collections_yet": "Du har ingen samlingar enno.",
"collections.remove_account": "Fjern denne kontoen",
"collections.search_accounts_label": "Søk etter kontoar å leggja til…",
"collections.search_accounts_max_reached": "Du har nådd grensa for kor mange kontoar du kan leggja til",
"collections.topic_hint": "Legg til ein emneknagg som hjelper andre å forstå hovudemnet for denne samlinga.",
"collections.view_collection": "Sjå samlinga",
"collections.visibility_public": "Offentleg",
@@ -442,6 +462,8 @@
"emoji_button.search_results": "Søkeresultat",
"emoji_button.symbols": "Symbol",
"emoji_button.travel": "Reise & stader",
"empty_column.account_about.me": "Du har ikkje skrive noko om deg sjølv enno.",
"empty_column.account_about.other": "{acct} har ikkje skrive noko om seg sjølv enno.",
"empty_column.account_featured.me": "Du har ikkje valt ut noko enno. Visste du at du kan velja ut emneknaggar du bruker mykje, og til og med venekontoar på profilen din?",
"empty_column.account_featured.other": "{acct} har ikkje valt ut noko enno. Visste du at du kan velja ut emneknaggar du bruker mykje, og til og med venekontoar på profilen din?",
"empty_column.account_featured_other.unknown": "Denne kontoen har ikkje valt ut noko enno.",
@@ -525,6 +547,8 @@
"follow_suggestions.view_all": "Vis alle",
"follow_suggestions.who_to_follow": "Kven du kan fylgja",
"followed_tags": "Fylgde emneknaggar",
"followers.hide_other_followers": "Denne personen har valt å ikkje syna dei andre fylgjarane sine",
"following.hide_other_following": "Denne personen har valt å ikkje syna kven andre dei fylgjer",
"footer.about": "Om",
"footer.about_mastodon": "Om Mastodon",
"footer.about_server": "Om {domain}",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Ktheji zërin @{name}",
"account.unmute_notifications_short": "Shfaqi njoftimet",
"account.unmute_short": "Çheshtoje",
"account_edit.column_button": "U bë",
"account_edit.column_title": "Përpunoni Profil",
"account_note.placeholder": "Klikoni për të shtuar shënim",
"admin.dashboard.daily_retention": "Shkallë mbajtjeje përdoruesi, në ditë, pas regjistrimit",
"admin.dashboard.monthly_retention": "Shkallë mbajtjeje përdoruesi, në muaj, pas regjistrimit",

View File

@@ -92,6 +92,8 @@
"account.unmute": "Sluta tysta @{name}",
"account.unmute_notifications_short": "Aktivera aviseringsljud",
"account.unmute_short": "Avtysta",
"account_edit.column_button": "Klar",
"account_edit.column_title": "Redigera profil",
"account_note.placeholder": "Klicka för att lägga till anteckning",
"admin.dashboard.daily_retention": "Användarlojalitet per dag efter registrering",
"admin.dashboard.monthly_retention": "Användarlojalitet per månad efter registrering",

View File

@@ -244,9 +244,12 @@
"closed_registrations_modal.preamble": "Mastodon merkeziyetsizdir, bu yüzden hesabınızı nerede oluşturursanız oluşturun, bu sunucudaki herhangi birini takip edebilecek veya onunla etkileşebileceksiniz. Hatta kendi sunucunuzu bile barındırabilirsiniz!",
"closed_registrations_modal.title": "Mastodon'a kayıt olmak",
"collections.account_count": "{count, plural, one {# hesap} other {# hesap}}",
"collections.accounts.empty_description": "Takip ettiğiniz hesapların sayısını {count} kadar artırın",
"collections.accounts.empty_title": "Bu koleksiyon boş",
"collections.collection_description": "Açıklama",
"collections.collection_name": "Ad",
"collections.collection_topic": "Konu",
"collections.confirm_account_removal": "Bu hesabı bu koleksiyondan çıkarmak istediğinizden emin misiniz?",
"collections.content_warning": "İçerik uyarısı",
"collections.continue": "Devam et",
"collections.create.accounts_subtitle": "Yalnızca keşif seçeneğini etkinleştirmiş takip ettiğiniz hesaplar eklenebilir.",
@@ -261,6 +264,9 @@
"collections.edit_details": "Temel bilgileri düzenle",
"collections.edit_settings": "Ayarları düzenle",
"collections.error_loading_collections": "Koleksiyonlarınızı yüklemeye çalışırken bir hata oluştu.",
"collections.hints.accounts_counter": "{count} / {max} hesap",
"collections.hints.add_more_accounts": "Devam etmek için en az {count, plural, one {# hesap} other {# hesap}} ekleyin",
"collections.hints.can_not_remove_more_accounts": "Koleksiyonlar en azından {count, plural, one {# hesap} other {# hesap}} içermelidir. Daha fazla hesap çıkarmak mümkün değil.",
"collections.last_updated_at": "Son güncelleme: {date}",
"collections.manage_accounts": "Hesapları yönet",
"collections.manage_accounts_in_collection": "Bu koleksiyondaki hesapları yönet",
@@ -269,6 +275,9 @@
"collections.name_length_hint": "100 karakterle sınırlı",
"collections.new_collection": "Yeni koleksiyon",
"collections.no_collections_yet": "Henüz hiçbir koleksiyon yok.",
"collections.remove_account": "Bu hesabı çıkar",
"collections.search_accounts_label": "Eklemek için hesap arayın…",
"collections.search_accounts_max_reached": "Maksimum hesabı eklediniz",
"collections.topic_hint": "Bu koleksiyonun ana konusunu başkalarının anlamasına yardımcı olacak bir etiket ekleyin.",
"collections.view_collection": "Koleksiyonu görüntüle",
"collections.visibility_public": "Herkese açık",

View File

@@ -141,6 +141,8 @@
"account.unmute": "Bỏ phớt lờ @{name}",
"account.unmute_notifications_short": "Bỏ phớt lờ thông báo",
"account.unmute_short": "Bỏ phớt lờ",
"account_edit.column_button": "Xong",
"account_edit.column_title": "Sửa hồ sơ",
"account_note.placeholder": "Nhấn để thêm",
"admin.dashboard.daily_retention": "Tỉ lệ người dùng sau đăng ký ở lại theo ngày",
"admin.dashboard.monthly_retention": "Tỉ lệ người dùng ở lại sau khi đăng ký",

View File

@@ -141,6 +141,8 @@
"account.unmute": "解除靜音 @{name}",
"account.unmute_notifications_short": "解除靜音推播通知",
"account.unmute_short": "解除靜音",
"account_edit.column_button": "完成",
"account_edit.column_title": "編輯個人檔案",
"account_note.placeholder": "點擊以新增備註",
"admin.dashboard.daily_retention": "註冊後使用者存留率(日)",
"admin.dashboard.monthly_retention": "註冊後使用者存留率(月)",
@@ -245,7 +247,7 @@
"closed_registrations_modal.title": "註冊 Mastodon",
"collections.account_count": "{count, plural, other {# 個帳號}}",
"collections.accounts.empty_description": "加入最多 {count} 個您跟隨之帳號",
"collections.accounts.empty_title": "此收藏名單空的",
"collections.accounts.empty_title": "此收藏名單空的",
"collections.collection_description": "說明",
"collections.collection_name": "名稱",
"collections.collection_topic": "主題",

View File

@@ -14,7 +14,7 @@ import { CustomEmojiFactory } from './custom_emoji';
import type { CustomEmoji } from './custom_emoji';
// AccountField
interface AccountFieldShape extends Required<ApiAccountFieldJSON> {
export interface AccountFieldShape extends Required<ApiAccountFieldJSON> {
name_emojified: string;
value_emojified: string;
value_plain: string | null;

View File

@@ -18,7 +18,7 @@ export function isServerFeatureEnabled(feature: ServerFeatures) {
return initialState?.features.includes(feature) ?? false;
}
type ClientFeatures = 'collections';
type ClientFeatures = 'collections' | 'profile_editing';
export function isClientFeatureEnabled(feature: ClientFeatures) {
try {

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z"/></svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z"/></svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@@ -4192,7 +4192,8 @@ a.account__display-name {
cursor: pointer;
& > div {
background: rgb(from var(--color-shadow-primary) r g b / 60%);
color: var(--color-text-on-media);
background: rgb(from var(--color-bg-media) r g b / 60%);
border-radius: 8px;
padding: 12px 9px;
backdrop-filter: $backdrop-blur-filter;
@@ -4205,7 +4206,7 @@ a.account__display-name {
button,
a {
display: inline;
color: var(--color-text-primary);
color: var(--color-text-on-media);
background: transparent;
border: 0;
padding: 0 8px;
@@ -4216,7 +4217,7 @@ a.account__display-name {
&:hover,
&:active,
&:focus {
color: var(--color-text-primary);
color: var(--color-text-on-media);
}
}

View File

@@ -27,9 +27,9 @@ class Fasp::Request
headers = request_headers(verb, url, body)
key = Linzer.new_ed25519_key(@provider.server_private_key_pem, @provider.remote_identifier)
response = HTTP
.headers(headers)
.use(http_signature: { key:, covered_components: COVERED_COMPONENTS })
.send(verb, url, body:)
.headers(headers)
.use(http_signature: { key:, covered_components: COVERED_COMPONENTS })
.send(verb, url, body:)
validate!(response)
@provider.delivery_failure_tracker.track_success!

View File

@@ -239,10 +239,10 @@ class FeedManager
# This is a bit tricky because we need posts tagged with this hashtag that are not
# also tagged with another followed hashtag or from a followed user
scope = from_tag.statuses
.where(id: timeline_status_ids)
.where.not(account: into_account)
.where.not(account: into_account.following)
.tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id))
.where(id: timeline_status_ids)
.where.not(account: into_account)
.where.not(account: into_account.following)
.tagged_with_none(TagFollow.where(account: into_account).pluck(:tag_id))
scope.select(:id, :reblog_of_id).reorder(nil).find_each do |status|
remove_from_feed(:home, into_account.id, status, aggregate_reblogs: into_account.user&.aggregates_reblogs?)

View File

@@ -18,8 +18,8 @@ class Vacuum::StatusesVacuum
# Side-effects not covered by foreign keys, such
# as the search index, must be handled first.
statuses.direct_visibility
.includes(mentions: :account)
.find_each(&:unlink_from_conversations!)
.includes(mentions: :account)
.find_each(&:unlink_from_conversations!)
if Chewy.enabled?
remove_from_index(statuses.ids, 'chewy:queue:StatusesIndex')
remove_from_index(statuses.ids, 'chewy:queue:PublicStatusesIndex')
@@ -33,8 +33,8 @@ class Vacuum::StatusesVacuum
def statuses_scope
Status.unscoped.kept
.joins(:account).merge(Account.remote)
.where(statuses: { id: ...retention_period_as_id })
.joins(:account).merge(Account.remote)
.where(statuses: { id: ...retention_period_as_id })
end
def retention_period_as_id

View File

@@ -46,6 +46,9 @@
# reviewed_at :datetime
# sensitized_at :datetime
# shared_inbox_url :string default(""), not null
# show_featured :boolean default(TRUE), not null
# show_media :boolean default(TRUE), not null
# show_media_replies :boolean default(TRUE), not null
# silenced_at :datetime
# suspended_at :datetime
# suspension_origin :integer

View File

@@ -45,7 +45,7 @@ module Account::Interactions
def follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
rel = active_relationships.create_with(show_reblogs: reblogs.nil? || reblogs, notify: notify.nil? ? false : notify, languages: languages, uri: uri, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
.find_or_create_by!(target_account: other_account)
.find_or_create_by!(target_account: other_account)
rel.show_reblogs = reblogs unless reblogs.nil?
rel.notify = notify unless notify.nil?
@@ -58,7 +58,7 @@ module Account::Interactions
def request_follow!(other_account, reblogs: nil, notify: nil, languages: nil, uri: nil, rate_limit: false, bypass_limit: false)
rel = follow_requests.create_with(show_reblogs: reblogs.nil? || reblogs, notify: notify.nil? ? false : notify, uri: uri, languages: languages, rate_limit: rate_limit, bypass_follow_limit: bypass_limit)
.find_or_create_by!(target_account: other_account)
.find_or_create_by!(target_account: other_account)
rel.show_reblogs = reblogs unless reblogs.nil?
rel.notify = notify unless notify.nil?
@@ -71,7 +71,7 @@ module Account::Interactions
def block!(other_account, uri: nil)
block_relationships.create_with(uri: uri)
.find_or_create_by!(target_account: other_account)
.find_or_create_by!(target_account: other_account)
end
def mute!(other_account, notifications: nil, duration: 0)
@@ -215,14 +215,14 @@ module Account::Interactions
def followers_for_local_distribution
followers.local
.joins(:user)
.merge(User.signed_in_recently)
.joins(:user)
.merge(User.signed_in_recently)
end
def lists_for_local_distribution
scope = lists.joins(account: :user)
scope.where.not(list_accounts: { follow_id: nil }).or(scope.where(account_id: id))
.merge(User.signed_in_recently)
.merge(User.signed_in_recently)
end
def remote_followers_hash(url)

View File

@@ -5,6 +5,6 @@ module Fasp::Provider::DebugConcern
def perform_debug_call
Fasp::Request.new(self)
.post('/debug/v0/callback/logs', body: { hello: 'world' })
.post('/debug/v0/callback/logs', body: { hello: 'world' })
end
end

View File

@@ -216,11 +216,11 @@ class MediaAttachment < ApplicationRecord
scope :updated_before, ->(value) { where(arel_table[:updated_at].lt(value)) }
scope :without_local_interaction, lambda {
where.not(Favourite.joins(:account).merge(Account.local).where(Favourite.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Bookmark.where(Bookmark.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Status.local.where(Status.arel_table[:in_reply_to_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Status.local.where(Status.arel_table[:reblog_of_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Quote.joins(:status).merge(Status.local).where(Quote.arel_table[:quoted_status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Quote.joins(:quoted_status).merge(Status.local).where(Quote.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Bookmark.where(Bookmark.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Status.local.where(Status.arel_table[:in_reply_to_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Status.local.where(Status.arel_table[:reblog_of_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Quote.joins(:status).merge(Status.local).where(Quote.arel_table[:quoted_status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
.where.not(Quote.joins(:quoted_status).merge(Status.local).where(Quote.arel_table[:status_id].eq(MediaAttachment.arel_table[:status_id])).select(1).arel.exists)
}
attr_accessor :skip_download

View File

@@ -375,19 +375,19 @@ class Status < ApplicationRecord
# _from_me part does not require any timeline filters
query_from_me = where(account_id: account.id)
.direct_visibility
.limit(limit)
.order(id: :desc)
.direct_visibility
.limit(limit)
.order(id: :desc)
# _to_me part requires mute and block filter.
# FIXME: may we check mutes.hide_notifications?
query_to_me = Status
.direct_visibility
.joins(:mentions)
.where(mentions: { account_id: account.id })
.limit(limit)
.order('mentions.status_id DESC')
.not_excluded_by_account(account)
.direct_visibility
.joins(:mentions)
.where(mentions: { account_id: account.id })
.limit(limit)
.order('mentions.status_id DESC')
.not_excluded_by_account(account)
if max_id.present?
query_from_me = query_from_me.where(id: ...max_id)

Some files were not shown because too many files have changed in this diff Show More