Merge commit '3473b8a65278783bc74ce2738aea98cca0c7a5ed' into glitch-soc/merge-upstream
Conflicts: - `config/settings.yml`: Upstream added a new setting textually adjacent to ones modified by glitch-soc's. Added upstream's new setting.
This commit is contained in:
commit
b3bae7e78f
104 files changed
+1213
-186
No files matched your search
@@ -82,7 +82,14 @@ class About extends PureComponent {
|
||||
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
|
||||
<div className='scrollable about'>
|
||||
<div className='about__header'>
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' />
|
||||
<ServerHeroImage
|
||||
withAltBadge
|
||||
alt={server.getIn(['thumbnail', 'description']) ?? ''}
|
||||
blurhash={server.getIn(['thumbnail', 'blurhash'])}
|
||||
src={server.getIn(['thumbnail', 'url'])}
|
||||
srcSet={server.getIn(['thumbnail', 'versions'])?.map((value, key) => `${value} ${key.replace('@', '')}`).join(', ')}
|
||||
className='about__header__hero'
|
||||
/>
|
||||
<h1>{isLoading ? <Skeleton width='10ch' /> : server.get('domain')}</h1>
|
||||
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AccountEditEmptyColumn: FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
@@ -38,7 +38,7 @@ export const AccountEditColumn: FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
title={title}
|
||||
className={classes.columnHeader}
|
||||
@@ -53,7 +53,7 @@ export const AccountEditColumn: FC<{
|
||||
}
|
||||
/>
|
||||
|
||||
{children}
|
||||
<div className='scrollable'>{children}</div>
|
||||
</Column>
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ToggleField } from '@/mastodon/components/form_fields';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import {
|
||||
fetchProfile,
|
||||
@@ -175,7 +176,7 @@ export const AccountEdit: FC = () => {
|
||||
}, [dispatch, profile?.bot]);
|
||||
|
||||
// Normally we would use the account emoji, but we want all custom emojis to be available to render after editing.
|
||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||
const emojis = useCustomEmojis();
|
||||
const htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: profile?.id,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { FC, FocusEventHandler } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
@@ -9,6 +15,7 @@ import { closeModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import type { FieldStatus } from '@/mastodon/components/form_fields';
|
||||
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
import {
|
||||
removeField,
|
||||
selectFieldById,
|
||||
@@ -104,11 +111,6 @@ const selectFieldLimits = createAppSelector(
|
||||
|
||||
const RECOMMENDED_LIMIT = 40;
|
||||
|
||||
const selectEmojiCodes = createAppSelector(
|
||||
[(state) => state.custom_emojis],
|
||||
(emojis) => emojis.map((emoji) => emoji.get('shortcode')).toArray(),
|
||||
);
|
||||
|
||||
interface ConfirmationMessage {
|
||||
message: string;
|
||||
confirm: string;
|
||||
@@ -143,7 +145,11 @@ export const EditFieldModal = forwardRef<
|
||||
value?: FieldStatus;
|
||||
}>({});
|
||||
|
||||
const customEmojiCodes = useAppSelector(selectEmojiCodes);
|
||||
const customEmojis = useCustomEmojis();
|
||||
const customEmojiCodes = useMemo(
|
||||
() => Object.keys(customEmojis ?? {}),
|
||||
[customEmojis],
|
||||
);
|
||||
const checkField = useCallback(
|
||||
(value: string): FieldStatus | null => {
|
||||
if (!value.trim()) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { CSS } from '@dnd-kit/utilities';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { normalizeKey } from '@/mastodon/components/hotkeys/utils';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
import type { FieldData } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
patchProfile,
|
||||
@@ -217,7 +218,7 @@ export const ReorderFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
void dispatch(patchProfile({ fields_attributes: newFields })).then(onClose);
|
||||
}, [dispatch, fieldKeys, fields, onClose]);
|
||||
|
||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||
const emojis = useCustomEmojis();
|
||||
|
||||
return (
|
||||
// Add a wrapper here in the capture phase, so that it can be intercepted before the window listener in ModalRoot.
|
||||
|
||||
@@ -126,11 +126,6 @@
|
||||
|
||||
// Column component
|
||||
|
||||
.column {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
:global(.column-header__buttons) {
|
||||
align-items: center;
|
||||
@@ -258,6 +253,10 @@
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
font-size: 15px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
|
||||
@@ -25,12 +25,9 @@ import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import {
|
||||
fetchAccountCollections,
|
||||
selectAccountCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { useAccountCollections } from '../collections';
|
||||
import { CollectionListItem } from '../collections/components/collection_list_item';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
|
||||
@@ -59,10 +56,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
void dispatch(fetchEndorsedAccounts({ accountId }));
|
||||
|
||||
if (collectionsEnabled) {
|
||||
void dispatch(fetchAccountCollections({ accountId }));
|
||||
}
|
||||
}
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
@@ -73,9 +66,8 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
||||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
const { collections, status: collectionsLoadStatus } = useAppSelector(
|
||||
(state) => selectAccountCollections(state, accountId ?? null),
|
||||
);
|
||||
const { collections, status: collectionsLoadStatus } =
|
||||
useAccountCollections(accountId);
|
||||
|
||||
const { listedCollections = [], unlistedCollections = [] } = Object.groupBy(
|
||||
collections,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import {
|
||||
NumberFields,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from '@/mastodon/components/number_fields';
|
||||
import { ShortNumber } from '@/mastodon/components/short_number';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
accountId,
|
||||
@@ -21,6 +23,13 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
[account?.created_at],
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const showJoinModal = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({ modalType: 'ACCOUNT_JOIN_DATE', modalProps: { accountId } }),
|
||||
);
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,15 +69,17 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
|
||||
}
|
||||
hint={intl.formatDate(account.created_at)}
|
||||
>
|
||||
{createdThisYear ? (
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
) : (
|
||||
<FormattedDateWrapper value={account.created_at} year='numeric' />
|
||||
)}
|
||||
<button type='button' onClick={showJoinModal}>
|
||||
{createdThisYear ? (
|
||||
<FormattedDateWrapper
|
||||
value={account.created_at}
|
||||
month='short'
|
||||
day='2-digit'
|
||||
/>
|
||||
) : (
|
||||
<FormattedDateWrapper value={account.created_at} year='numeric' />
|
||||
)}
|
||||
</button>
|
||||
</NumberFieldsItem>
|
||||
</NumberFields>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { AccountField } from '../common';
|
||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const AccountFieldModal: FC<{
|
||||
onClose: () => void;
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import AnniversaryImage from '@/images/anniversary.svg?react';
|
||||
import { focusCompose, resetCompose } from '@/mastodon/actions/compose';
|
||||
import { closeModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { ModalShell, ModalShellBody } from '@/mastodon/components/modal_shell';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const closeMessage = defineMessage({
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
});
|
||||
|
||||
const selectServerName = createAppSelector(
|
||||
[
|
||||
(state) => state.accounts,
|
||||
(_, accountId: string) => accountId,
|
||||
(state) => state.server.getIn(['server', 'domain']) as string | undefined,
|
||||
],
|
||||
(accounts, accountId, serverDomain) => {
|
||||
const acct = accounts.getIn([accountId, 'acct']) as string | undefined;
|
||||
if (!acct) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const domain = acct.split('@').at(1);
|
||||
if (domain) {
|
||||
return domain;
|
||||
}
|
||||
|
||||
return serverDomain;
|
||||
},
|
||||
);
|
||||
|
||||
export const AccountJoinModal: FC<{
|
||||
accountId: string;
|
||||
onClose: () => void;
|
||||
}> = ({ accountId, onClose }) => {
|
||||
const intl = useIntl();
|
||||
const account = useAccount(accountId);
|
||||
const currentId = useCurrentAccountId();
|
||||
const isMe = accountId === currentId;
|
||||
|
||||
const createdAtStr = account?.created_at;
|
||||
const anniversary = useMemo(() => {
|
||||
if (!createdAtStr) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
const createdAt = new Date(createdAtStr);
|
||||
if (
|
||||
now.getMonth() === createdAt.getMonth() &&
|
||||
now.getDate() === createdAt.getDate()
|
||||
) {
|
||||
return now.getFullYear() - createdAt.getFullYear();
|
||||
}
|
||||
return null;
|
||||
}, [createdAtStr]);
|
||||
|
||||
const domain = useAppSelector((state) => selectServerName(state, accountId));
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handle = account?.acct;
|
||||
const handleShare = useCallback(() => {
|
||||
if (anniversary === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shareText = '#Fediversary';
|
||||
if (anniversary === 0) {
|
||||
shareText = isMe ? '#firstday' : '#welcome';
|
||||
}
|
||||
|
||||
if (!isMe && handle) {
|
||||
shareText = `@${handle} ${shareText}`;
|
||||
}
|
||||
|
||||
dispatch(resetCompose());
|
||||
dispatch(focusCompose(`\n\n${shareText}`, true));
|
||||
dispatch(closeModal({ modalType: 'ACCOUNT_JOIN_DATE', ignoreFocus: true }));
|
||||
}, [anniversary, handle, dispatch, isMe]);
|
||||
|
||||
return (
|
||||
<ModalShell className={classes.joinShell}>
|
||||
<ModalShellBody className={classes.joinWrapper}>
|
||||
<AccountAnniversaryImage anniversary={anniversary} />
|
||||
|
||||
<div>
|
||||
<AccountJoinMessage
|
||||
name={<DisplayNameSimple account={account} />}
|
||||
isMe={isMe}
|
||||
serverName={domain}
|
||||
anniversary={anniversary}
|
||||
/>
|
||||
<h1>
|
||||
<FormattedDateWrapper
|
||||
value={account?.created_at}
|
||||
month='short'
|
||||
day='numeric'
|
||||
year='numeric'
|
||||
/>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<AccountAnniversaryShare
|
||||
anniversary={anniversary}
|
||||
onShare={handleShare}
|
||||
isMe={isMe}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
iconComponent={CloseIcon}
|
||||
icon='times'
|
||||
onClick={onClose}
|
||||
title={intl.formatMessage(closeMessage)}
|
||||
className={classes.joinClose}
|
||||
/>
|
||||
</ModalShellBody>
|
||||
</ModalShell>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountJoinMessage: FC<{
|
||||
name: React.JSX.Element;
|
||||
isMe: boolean;
|
||||
serverName?: string;
|
||||
anniversary: number | null;
|
||||
}> = ({ name, isMe, serverName, anniversary }) => {
|
||||
if (anniversary === 0) {
|
||||
if (isMe) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.me_today'
|
||||
defaultMessage='It’s your first day on {server}!'
|
||||
tagName='p'
|
||||
values={{
|
||||
server: serverName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.other_today'
|
||||
defaultMessage='It’s {name}’s first day on {server}!'
|
||||
tagName='p'
|
||||
values={{
|
||||
name,
|
||||
server: serverName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMe) {
|
||||
if (anniversary !== null && anniversary > 0) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.me_anniversary'
|
||||
defaultMessage='Happy Fediversary! You joined {server} on'
|
||||
tagName='p'
|
||||
values={{
|
||||
server: serverName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.me'
|
||||
defaultMessage='You joined {server} on'
|
||||
tagName='p'
|
||||
values={{
|
||||
server: serverName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.other'
|
||||
defaultMessage='{name} joined {server} on'
|
||||
tagName='p'
|
||||
values={{
|
||||
name,
|
||||
server: serverName,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountAnniversaryImage: FC<{ anniversary: number | null }> = ({
|
||||
anniversary,
|
||||
}) => {
|
||||
if (anniversary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.joinBanner}>
|
||||
<AnniversaryImage role='presentation' />
|
||||
<h2>{anniversary || 1}</h2>
|
||||
{anniversary === 0 && (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.day'
|
||||
defaultMessage='Day'
|
||||
tagName='h3'
|
||||
/>
|
||||
)}
|
||||
{anniversary > 0 && (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.years'
|
||||
defaultMessage='{number, plural, one {year} other {years}}'
|
||||
values={{ number: anniversary }}
|
||||
tagName='h3'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountAnniversaryShare: FC<{
|
||||
anniversary: number | null;
|
||||
onShare: () => void;
|
||||
isMe: boolean;
|
||||
}> = ({ anniversary, onShare, isMe }) => {
|
||||
if (anniversary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={onShare}>
|
||||
{anniversary === 0 && isMe && (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.share.intro'
|
||||
defaultMessage='Share an intro post'
|
||||
/>
|
||||
)}
|
||||
{anniversary === 0 && !isMe && (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.share.welcome'
|
||||
defaultMessage='Share a welcome post'
|
||||
/>
|
||||
)}
|
||||
{anniversary > 0 && (
|
||||
<FormattedMessage
|
||||
id='account.join_modal.share.celebrate'
|
||||
defaultMessage='Share a celebratory post'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
newTitle: {
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
.noteCallout {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.noteInput {
|
||||
min-height: 70px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
appearance: none;
|
||||
resize: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.noteInput:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fieldName,
|
||||
.fieldValue {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
@use '@/styles/mastodon/variables' as *;
|
||||
|
||||
.noteCallout {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.noteInput {
|
||||
min-height: 70px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
appearance: none;
|
||||
resize: none;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.noteInput:focus-visible {
|
||||
outline: var(--outline-focus-default);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fieldName,
|
||||
.fieldValue {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: ($mobile-breakpoint + 1)) {
|
||||
.joinShell {
|
||||
> :global(.safety-action-modal__top) {
|
||||
border-bottom-left-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.joinWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--color-text-primary);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
gap: 16px;
|
||||
|
||||
p,
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.joinBanner {
|
||||
width: 120px;
|
||||
height: 110px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 40px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.joinClose {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
|
||||
import { getCollectionPath } from '../utils';
|
||||
|
||||
import classes from './collection_lockup.module.scss';
|
||||
|
||||
export const AvatarGrid: React.FC<{
|
||||
@@ -27,10 +29,10 @@ export const AvatarGrid: React.FC<{
|
||||
sensitive ? classes.avatarGridSensitive : null,
|
||||
)}
|
||||
>
|
||||
{avatarIds.map((id) => (
|
||||
{avatarIds.map((id, index) => (
|
||||
<AvatarById
|
||||
animate={false}
|
||||
key={id}
|
||||
key={index}
|
||||
accountId={id}
|
||||
className={classes.avatar}
|
||||
size={25}
|
||||
@@ -67,7 +69,7 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
|
||||
/>
|
||||
<div>
|
||||
<h2 id={linkId}>
|
||||
<Link to={`/collections/${id}`} className={classes.link}>
|
||||
<Link to={getCollectionPath(id)} className={classes.link}>
|
||||
{name}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { messages as editorMessages } from '../editor';
|
||||
import { getCollectionPath } from '../utils';
|
||||
|
||||
const messages = defineMessages({
|
||||
view: {
|
||||
@@ -120,7 +121,7 @@ export const CollectionMenu: React.FC<{
|
||||
const menu = useMemo(() => {
|
||||
const viewCollectionItem: MenuItem = {
|
||||
text: intl.formatMessage(messages.view),
|
||||
to: `/collections/${id}`,
|
||||
to: getCollectionPath(id),
|
||||
};
|
||||
const shareItems: MenuItem[] = [
|
||||
{
|
||||
@@ -130,7 +131,7 @@ export const CollectionMenu: React.FC<{
|
||||
{
|
||||
text: intl.formatMessage(messages.copyLink),
|
||||
action: () => {
|
||||
void navigator.clipboard.writeText(`/collections/${id}`);
|
||||
void navigator.clipboard.writeText(getCollectionPath(id));
|
||||
dispatch(showAlert({ message: messages.copyLinkConfirmation }));
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { CollectionLockupProps } from 'mastodon/features/collections/components/collection_lockup';
|
||||
import { CollectionLockup } from 'mastodon/features/collections/components/collection_lockup';
|
||||
|
||||
@@ -13,7 +15,7 @@ export const CollectionPreviewCard: React.FC<CollectionPreviewCardProps> = ({
|
||||
...otherProps
|
||||
}) => {
|
||||
return (
|
||||
<div className={classes.wrapper}>
|
||||
<div className={classNames(classes.wrapper, 'collection-preview')}>
|
||||
<CollectionLockup collection={collection} {...otherProps} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useHistory } from 'react-router-dom';
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { ComboboxMenuItem } from '@/mastodon/components/form_fields/combobox_field';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { languages } from '@/mastodon/initial_state';
|
||||
import {
|
||||
hasSpecialCharacters,
|
||||
@@ -36,6 +38,8 @@ import {
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { getCollectionPath } from '../utils';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
import { WizardStepTitle } from './wizard_step_title';
|
||||
|
||||
@@ -93,6 +97,9 @@ export const CollectionDetails: React.FC = () => {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const accountId = useCurrentAccountId();
|
||||
const { acct: currentUserName } = useAccount(accountId) ?? {};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -128,8 +135,8 @@ export const CollectionDetails: React.FC = () => {
|
||||
}),
|
||||
).then((result) => {
|
||||
if (isFulfilled(result)) {
|
||||
history.replace(`/collections`);
|
||||
history.push(`/collections/${result.payload.collection.id}`, {
|
||||
history.replace(`/@${currentUserName}/collections`);
|
||||
history.push(getCollectionPath(result.payload.collection.id), {
|
||||
newCollection: true,
|
||||
});
|
||||
}
|
||||
@@ -146,6 +153,7 @@ export const CollectionDetails: React.FC = () => {
|
||||
dispatch,
|
||||
history,
|
||||
accountIds,
|
||||
currentUserName,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
useLocation,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { initialState } from '@/mastodon/initial_state';
|
||||
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
@@ -22,8 +25,11 @@ import {
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { useAccountCollections } from '..';
|
||||
|
||||
import { CollectionAccounts } from './accounts';
|
||||
import { CollectionDetails } from './details';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const messages = defineMessages({
|
||||
create: {
|
||||
@@ -61,11 +67,14 @@ function usePageTitle(id: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export const userCollectionLimit = initialState?.role?.collection_limit ?? 0;
|
||||
|
||||
export const CollectionEditorPage: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountId = useCurrentAccountId();
|
||||
const { id = null } = useParams<{ id?: string }>();
|
||||
const { path } = useRouteMatch();
|
||||
const collection = useAppSelector((state) =>
|
||||
@@ -73,7 +82,18 @@ export const CollectionEditorPage: React.FC<{
|
||||
);
|
||||
const editorStateId = useAppSelector((state) => state.collections.editor.id);
|
||||
const isEditMode = !!id;
|
||||
const isLoading = isEditMode && !collection;
|
||||
|
||||
// When creating a new collection, we load the current account's collections
|
||||
// to determine if they're allowed to create more.
|
||||
const { collections: collectionList, status: collectionListStatus } =
|
||||
useAccountCollections(isEditMode ? null : accountId);
|
||||
|
||||
const isLoading =
|
||||
(isEditMode && !collection) ||
|
||||
(!isEditMode && collectionListStatus === 'loading');
|
||||
|
||||
const canCreateMoreCollections =
|
||||
isEditMode || collectionList.length < userCollectionLimit;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -108,7 +128,7 @@ export const CollectionEditorPage: React.FC<{
|
||||
<div className='scrollable'>
|
||||
{isLoading ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
) : canCreateMoreCollections ? (
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
@@ -123,6 +143,8 @@ export const CollectionEditorPage: React.FC<{
|
||||
render={() => <CollectionDetails />}
|
||||
/>
|
||||
</Switch>
|
||||
) : (
|
||||
<MaxCollectionsCallout />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -133,3 +155,21 @@ export const CollectionEditorPage: React.FC<{
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export const MaxCollectionsCallout: React.FC = () => (
|
||||
<Callout
|
||||
className={classes.maxCollectionsError}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='collections.maximum_collection_count_reached'
|
||||
defaultMessage='You have created the maximum number of collections'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='collections.maximum_collection_count_description'
|
||||
defaultMessage='Your server allows creation of up to {count} collections.'
|
||||
values={{ count: userCollectionLimit }}
|
||||
/>
|
||||
</Callout>
|
||||
);
|
||||
@@ -77,3 +77,7 @@
|
||||
.suggestionGroup {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.maxCollectionsError {
|
||||
margin: 16px;
|
||||
}
|
||||
@@ -25,7 +25,12 @@ import {
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { CollectionListItem } from './components/collection_list_item';
|
||||
import { messages as editorMessages } from './editor';
|
||||
import {
|
||||
messages as editorMessages,
|
||||
MaxCollectionsCallout,
|
||||
userCollectionLimit,
|
||||
} from './editor';
|
||||
import { areCollectionsEnabled } from './utils';
|
||||
|
||||
const messages = defineMessages({
|
||||
headingMe: { id: 'column.my_collections', defaultMessage: 'My collections' },
|
||||
@@ -35,24 +40,27 @@ const messages = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
export function useAccountCollections(accountId: string | null | undefined) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && areCollectionsEnabled()) {
|
||||
void dispatch(fetchAccountCollections({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
|
||||
return useAppSelector((state) => selectAccountCollections(state, accountId));
|
||||
}
|
||||
|
||||
export const Collections: React.FC<{
|
||||
multiColumn?: boolean;
|
||||
}> = ({ multiColumn }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
const me = useCurrentAccountId();
|
||||
const accountId = useAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const { collections, status } = useAppSelector((state) =>
|
||||
selectAccountCollections(state, accountId),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId) {
|
||||
void dispatch(fetchAccountCollections({ accountId }));
|
||||
}
|
||||
}, [dispatch, accountId]);
|
||||
const { collections, status } = useAccountCollections(accountId);
|
||||
|
||||
const emptyMessage =
|
||||
status === 'error' || !accountId ? (
|
||||
@@ -79,6 +87,7 @@ export const Collections: React.FC<{
|
||||
</>
|
||||
);
|
||||
|
||||
const canCreateMoreCollections = collections.length < userCollectionLimit;
|
||||
const isOwnCollection = accountId === me;
|
||||
const titleMessage = isOwnCollection
|
||||
? messages.headingMe
|
||||
@@ -99,7 +108,9 @@ export const Collections: React.FC<{
|
||||
iconComponent={CollectionsFilledIcon}
|
||||
multiColumn={multiColumn}
|
||||
extraButton={
|
||||
isOwnCollection && (
|
||||
isOwnCollection &&
|
||||
status === 'idle' &&
|
||||
canCreateMoreCollections && (
|
||||
<Link
|
||||
to='/collections/new'
|
||||
className='column-header__button'
|
||||
@@ -113,6 +124,9 @@ export const Collections: React.FC<{
|
||||
/>
|
||||
|
||||
<Scrollable>
|
||||
{status === 'idle' && !canCreateMoreCollections && (
|
||||
<MaxCollectionsCallout />
|
||||
)}
|
||||
<ItemList emptyMessage={emptyMessage} isLoading={status === 'loading'}>
|
||||
{collections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
|
||||
@@ -3,3 +3,5 @@ import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
export function areCollectionsEnabled() {
|
||||
return isServerFeatureEnabled('collections');
|
||||
}
|
||||
|
||||
export const getCollectionPath = (id: string) => `/collections/${id}`;
|
||||
@@ -267,6 +267,11 @@ export async function searchCustomEmojisByShortcodes(shortcodes: string[]) {
|
||||
return results.filter((emoji) => shortcodes.includes(emoji.shortcode));
|
||||
}
|
||||
|
||||
export async function loadAllCustomEmoji() {
|
||||
const db = await loadDB();
|
||||
return db.getAll('custom');
|
||||
}
|
||||
|
||||
export async function loadLegacyShortcodesByShortcode(shortcode: string) {
|
||||
const db = await loadDB();
|
||||
return db.getFromIndex(
|
||||
|
||||
@@ -185,21 +185,16 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg | null) {
|
||||
if (!extraEmojis) {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(extraEmojis)) {
|
||||
return extraEmojis.reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
if (!Array.isArray(extraEmojis) && !isList(extraEmojis)) {
|
||||
return extraEmojis;
|
||||
}
|
||||
if (isList(extraEmojis)) {
|
||||
return extraEmojis
|
||||
.toJS()
|
||||
.reduce<ExtraCustomEmojiMap>(
|
||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||
{},
|
||||
);
|
||||
const emojis: ExtraCustomEmojiMap = {};
|
||||
const emojiArray = isList(extraEmojis) ? extraEmojis.toJS() : extraEmojis;
|
||||
for (const emoji of emojiArray) {
|
||||
emojis[emoji.shortcode] = emoji;
|
||||
}
|
||||
return extraEmojis;
|
||||
|
||||
return emojis;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||
import type { RenderSlideFn } from '@/mastodon/components/carousel';
|
||||
import { Carousel } from '@/mastodon/components/carousel';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { useCustomEmojis } from '@/mastodon/hooks/useCustomEmojis';
|
||||
import { mascot } from '@/mastodon/initial_state';
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
@@ -23,7 +24,7 @@ const announcementSelector = createAppSelector(
|
||||
|
||||
export const Announcements: FC = () => {
|
||||
const announcements = useAppSelector(announcementSelector);
|
||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||
const emojis = useCustomEmojis();
|
||||
|
||||
const renderSlide: RenderSlideFn<{
|
||||
id: string;
|
||||
|
||||
@@ -29,9 +29,12 @@ import StatusContent from 'mastodon/components/status_content';
|
||||
import { QuotedStatus } from 'mastodon/components/status_quoted';
|
||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||
import { Audio } from 'mastodon/features/audio';
|
||||
import { CollectionPreviewCard } from 'mastodon/features/collections/components/collection_preview_card';
|
||||
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
import type { CollectionAttachment } from 'mastodon/models/status';
|
||||
import { compareUrls } from 'mastodon/utils/compare_urls';
|
||||
|
||||
import Card from './card';
|
||||
|
||||
@@ -260,13 +263,32 @@ export const DetailedStatus: React.FC<{
|
||||
);
|
||||
}
|
||||
} else if (status.get('card') && !status.get('quote')) {
|
||||
media = (
|
||||
<Card
|
||||
key={`${status.get('id')}-${status.get('edited_at')}`}
|
||||
sensitive={status.get('sensitive')}
|
||||
card={status.get('card')}
|
||||
/>
|
||||
);
|
||||
const cardUrl: string = status.getIn(['card', 'url']);
|
||||
|
||||
const taggedCollection = status
|
||||
.get('tagged_collections')
|
||||
.find((item: CollectionAttachment) =>
|
||||
compareUrls(item.get('url'), cardUrl),
|
||||
);
|
||||
|
||||
if (taggedCollection) {
|
||||
media = <CollectionPreviewCard collection={taggedCollection} />;
|
||||
} else {
|
||||
media = (
|
||||
<Card
|
||||
key={`${status.get('id')}-${status.get('edited_at')}`}
|
||||
sensitive={status.get('sensitive')}
|
||||
card={status.get('card')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (status.get('tagged_collections').size) {
|
||||
const firstLinkedCollection = status.get('tagged_collections').first();
|
||||
if (firstLinkedCollection) {
|
||||
media = (
|
||||
<CollectionPreviewCard collection={firstLinkedCollection.toJS()} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
|
||||
@@ -93,6 +93,7 @@ export const MODAL_COMPONENTS = {
|
||||
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
|
||||
'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
|
||||
'ACCOUNT_FIELD_OVERFLOW': () => import('@/mastodon/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })),
|
||||
'ACCOUNT_JOIN_DATE': () => import('@/mastodon/features/account_timeline/modals/join_modal').then(module => ({ default: module.AccountJoinModal })),
|
||||
'ACCOUNT_EDIT_NAME': accountEditModal('NameModal'),
|
||||
'ACCOUNT_EDIT_BIO': accountEditModal('BioModal'),
|
||||
'ACCOUNT_EDIT_PROFILE_DISPLAY': accountEditModal('ProfileDisplayModal'),
|
||||
|
||||
Reference in new issue
Block a user