Merge remote-tracking branch 'origin/develop' into redirect-unauth
This commit is contained in:
commit
6f83b7748e
|
@ -6,8 +6,8 @@ import { groupSchema } from 'soapbox/schemas';
|
||||||
interface UpdateGroupParams {
|
interface UpdateGroupParams {
|
||||||
display_name?: string
|
display_name?: string
|
||||||
note?: string
|
note?: string
|
||||||
avatar?: File
|
avatar?: File | ''
|
||||||
header?: File
|
header?: File | ''
|
||||||
group_visibility?: string
|
group_visibility?: string
|
||||||
discoverable?: boolean
|
discoverable?: boolean
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
|
|
@ -28,6 +28,7 @@ const messages = defineMessages({
|
||||||
domainBlocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
domainBlocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||||
|
followedTags: { id: 'navigation_bar.followed_tags', defaultMessage: 'Followed hashtags' },
|
||||||
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
|
soapboxConfig: { id: 'navigation_bar.soapbox_config', defaultMessage: 'Soapbox config' },
|
||||||
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
|
accountMigration: { id: 'navigation_bar.account_migration', defaultMessage: 'Move account' },
|
||||||
accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
|
accountAliases: { id: 'navigation_bar.account_aliases', defaultMessage: 'Account aliases' },
|
||||||
|
@ -305,6 +306,15 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{features.followedHashtagsList && (
|
||||||
|
<SidebarLink
|
||||||
|
to='/followed_tags'
|
||||||
|
icon={require('@tabler/icons/hash.svg')}
|
||||||
|
text={intl.formatMessage(messages.followedTags)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{account.admin && (
|
{account.admin && (
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to='/soapbox/config'
|
to='/soapbox/config'
|
||||||
|
|
|
@ -178,8 +178,15 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFeedSuggestions = (): React.ReactNode => {
|
const renderFeedSuggestions = (statusId: string): React.ReactNode => {
|
||||||
return <FeedSuggestions key='suggestions' />;
|
return (
|
||||||
|
<FeedSuggestions
|
||||||
|
key='suggestions'
|
||||||
|
statusId={statusId}
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
onMoveDown={handleMoveDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderStatuses = (): React.ReactNode[] => {
|
const renderStatuses = (): React.ReactNode[] => {
|
||||||
|
@ -201,7 +208,7 @@ const StatusList: React.FC<IStatusList> = ({
|
||||||
}
|
}
|
||||||
} else if (statusId.startsWith('末suggestions-')) {
|
} else if (statusId.startsWith('末suggestions-')) {
|
||||||
if (soapboxConfig.feedInjection) {
|
if (soapboxConfig.feedInjection) {
|
||||||
acc.push(renderFeedSuggestions());
|
acc.push(renderFeedSuggestions(statusId));
|
||||||
}
|
}
|
||||||
} else if (statusId.startsWith('末pending-')) {
|
} else if (statusId.startsWith('末pending-')) {
|
||||||
acc.push(renderPendingStatus(statusId));
|
acc.push(renderPendingStatus(statusId));
|
||||||
|
|
|
@ -67,7 +67,7 @@ const StillImage: React.FC<IStillImage> = ({ alt, className, src, style, letterb
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvas}
|
ref={canvas}
|
||||||
className={clsx(baseClassName, {
|
className={clsx(baseClassName, {
|
||||||
'group-hover:invisible': hoverToPlay,
|
'absolute group-hover:invisible top-0': hoverToPlay,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -27,6 +27,7 @@ interface ICard {
|
||||||
className?: string
|
className?: string
|
||||||
/** Elements inside the card. */
|
/** Elements inside the card. */
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
tabIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An opaque backdrop to hold a collection of related elements. */
|
/** An opaque backdrop to hold a collection of related elements. */
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||||
// @ts-ignore: it doesn't have types
|
// @ts-ignore: it doesn't have types
|
||||||
import { ScrollContext } from 'react-router-scroll-4';
|
import { ScrollContext } from 'react-router-scroll-4';
|
||||||
|
|
||||||
|
|
||||||
import { loadInstance } from 'soapbox/actions/instance';
|
import { loadInstance } from 'soapbox/actions/instance';
|
||||||
import { fetchMe } from 'soapbox/actions/me';
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
|
import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||||
|
|
|
@ -109,9 +109,13 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
|
return chatMessages.reduce((acc: any, curr: any, idx: number) => {
|
||||||
const lastMessage = formattedChatMessages[idx - 1];
|
const lastMessage = formattedChatMessages[idx - 1];
|
||||||
|
|
||||||
|
const messageDate = new Date(curr.created_at);
|
||||||
|
|
||||||
if (lastMessage) {
|
if (lastMessage) {
|
||||||
switch (timeChange(lastMessage, curr)) {
|
switch (timeChange(lastMessage, curr)) {
|
||||||
case 'today':
|
case 'today':
|
||||||
|
@ -123,7 +127,14 @@ const ChatMessageList: React.FC<IChatMessageList> = ({ chat }) => {
|
||||||
case 'date':
|
case 'date':
|
||||||
acc.push({
|
acc.push({
|
||||||
type: 'divider',
|
type: 'divider',
|
||||||
text: intl.formatDate(new Date(curr.created_at), { weekday: 'short', hour: 'numeric', minute: '2-digit', month: 'short', day: 'numeric' }),
|
text: intl.formatDate(messageDate, {
|
||||||
|
weekday: 'short',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: messageDate.getFullYear() !== currentYear ? '2-digit' : undefined,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,25 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
import { Avatar, Icon, HStack } from 'soapbox/components/ui';
|
||||||
import { Avatar, HStack } from 'soapbox/components/ui';
|
|
||||||
|
|
||||||
interface IMediaInput {
|
interface IMediaInput {
|
||||||
|
className?: string
|
||||||
src: string | undefined
|
src: string | undefined
|
||||||
accept: string
|
accept: string
|
||||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
|
const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ className, src, onChange, accept, disabled }, ref) => {
|
||||||
return (
|
return (
|
||||||
<label className='absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900'>
|
<label
|
||||||
|
className={clsx(
|
||||||
|
'absolute bottom-0 left-1/2 h-20 w-20 -translate-x-1/2 translate-y-1/2 cursor-pointer rounded-full bg-primary-500 ring-2 ring-white dark:ring-primary-900',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{src && <Avatar src={src} size={80} />}
|
{src && <Avatar src={src} size={80} />}
|
||||||
<HStack
|
<HStack
|
||||||
alignItems='center'
|
alignItems='center'
|
||||||
|
@ -28,7 +34,7 @@ const AvatarPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
|
||||||
className='h-5 w-5 text-white'
|
className='h-5 w-5 text-white'
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
<span className='sr-only'>Upload avatar</span>
|
<span className='sr-only'><FormattedMessage id='group.upload_avatar' defaultMessage='Upload avatar' /></span>
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
name='avatar'
|
name='avatar'
|
|
@ -1,21 +1,34 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
import { HStack, Icon, IconButton, Text } from 'soapbox/components/ui';
|
||||||
import { HStack, Text } from 'soapbox/components/ui';
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'group.upload_banner.title', defaultMessage: 'Upload background picture' },
|
||||||
|
});
|
||||||
|
|
||||||
interface IMediaInput {
|
interface IMediaInput {
|
||||||
src: string | undefined
|
src: string | undefined
|
||||||
accept: string
|
accept: string
|
||||||
onChange: React.ChangeEventHandler<HTMLInputElement>
|
onChange: React.ChangeEventHandler<HTMLInputElement>
|
||||||
|
onClear?: () => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, accept, disabled }, ref) => {
|
const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onChange, onClear, accept, disabled }, ref) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleClear: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
onClear!();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
|
className='dark:sm:shadow-inset relative h-24 w-full cursor-pointer overflow-hidden rounded-lg bg-primary-100 text-primary-500 dark:bg-gray-800 dark:text-accent-blue sm:h-36 sm:shadow'
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
>
|
>
|
||||||
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
|
{src && <img className='h-full w-full object-cover' src={src} alt='' />}
|
||||||
<HStack
|
<HStack
|
||||||
|
@ -45,6 +58,15 @@ const HeaderPicker = React.forwardRef<HTMLInputElement, IMediaInput>(({ src, onC
|
||||||
className='hidden'
|
className='hidden'
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
{onClear && src && (
|
||||||
|
<IconButton
|
||||||
|
onClick={handleClear}
|
||||||
|
src={require('@tabler/icons/x.svg')}
|
||||||
|
theme='dark'
|
||||||
|
className='absolute right-2 top-2 z-10 hover:scale-105 hover:bg-gray-900'
|
||||||
|
iconClassName='h-5 w-5'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
});
|
});
|
|
@ -1,49 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import StillImage from 'soapbox/components/still-image';
|
|
||||||
import { Avatar, HStack, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import VerificationBadge from 'soapbox/components/verification-badge';
|
|
||||||
import { useSoapboxConfig } from 'soapbox/hooks';
|
|
||||||
|
|
||||||
import type { Account } from 'soapbox/types/entities';
|
|
||||||
|
|
||||||
interface IProfilePreview {
|
|
||||||
account: Pick<Account, 'acct' | 'fqn' | 'avatar' | 'header' | 'verified' | 'display_name_html'>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Displays a preview of the user's account, including avatar, banner, etc. */
|
|
||||||
const ProfilePreview: React.FC<IProfilePreview> = ({ account }) => {
|
|
||||||
const { displayFqn } = useSoapboxConfig();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='dark:sm:shadow-inset overflow-hidden rounded-lg bg-white text-black dark:bg-gray-800 dark:text-white sm:shadow'>
|
|
||||||
<div className='relative isolate h-32 w-full overflow-hidden bg-gray-200 dark:bg-gray-900/50 md:rounded-t-lg'>
|
|
||||||
<StillImage src={account.header} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HStack space={3} alignItems='center' className='p-3'>
|
|
||||||
<div className='relative'>
|
|
||||||
<Avatar className='bg-gray-400' src={account.avatar} />
|
|
||||||
|
|
||||||
{account.verified && (
|
|
||||||
<div className='absolute -right-1.5 -top-1.5'>
|
|
||||||
<VerificationBadge />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Stack className='truncate'>
|
|
||||||
<Text
|
|
||||||
weight='medium'
|
|
||||||
size='sm'
|
|
||||||
truncate
|
|
||||||
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
|
|
||||||
/>
|
|
||||||
<Text theme='muted' size='sm'>@{displayFqn ? account.fqn : account.acct}</Text>
|
|
||||||
</Stack>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProfilePreview;
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
||||||
|
@ -8,7 +8,6 @@ import List, { ListItem } from 'soapbox/components/list';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Column,
|
Column,
|
||||||
FileInput,
|
|
||||||
Form,
|
Form,
|
||||||
FormActions,
|
FormActions,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
|
@ -18,16 +17,21 @@ import {
|
||||||
Textarea,
|
Textarea,
|
||||||
Toggle,
|
Toggle,
|
||||||
} from 'soapbox/components/ui';
|
} from 'soapbox/components/ui';
|
||||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
import { useAppDispatch, useOwnAccount, useFeatures, useInstance, useAppSelector } from 'soapbox/hooks';
|
||||||
import { accountSchema } from 'soapbox/schemas';
|
import { useImageField } from 'soapbox/hooks/forms';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
import resizeImage from 'soapbox/utils/resize-image';
|
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
import ProfilePreview from './components/profile-preview';
|
import AvatarPicker from './components/avatar-picker';
|
||||||
|
import HeaderPicker from './components/header-picker';
|
||||||
|
|
||||||
|
import type { List as ImmutableList } from 'immutable';
|
||||||
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
|
import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield/streamfield';
|
||||||
import type { Account } from 'soapbox/schemas';
|
import type { Account } from 'soapbox/schemas';
|
||||||
|
|
||||||
|
const nonDefaultAvatar = (url: string | undefined) => url && isDefaultAvatar(url) ? undefined : url;
|
||||||
|
const nonDefaultHeader = (url: string | undefined) => url && isDefaultHeader(url) ? undefined : url;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user is hiding their follows and/or followers.
|
* Whether the user is hiding their follows and/or followers.
|
||||||
* Pleroma's config is granular, but we simplify it into one setting.
|
* Pleroma's config is granular, but we simplify it into one setting.
|
||||||
|
@ -88,9 +92,9 @@ interface AccountCredentials {
|
||||||
/** The account bio. */
|
/** The account bio. */
|
||||||
note?: string
|
note?: string
|
||||||
/** Avatar image encoded using multipart/form-data */
|
/** Avatar image encoded using multipart/form-data */
|
||||||
avatar?: File
|
avatar?: File | ''
|
||||||
/** Header image encoded using multipart/form-data */
|
/** Header image encoded using multipart/form-data */
|
||||||
header?: File
|
header?: File | ''
|
||||||
/** Whether manual approval of follow requests is required. */
|
/** Whether manual approval of follow requests is required. */
|
||||||
locked?: boolean
|
locked?: boolean
|
||||||
/** Private information (settings) about the account. */
|
/** Private information (settings) about the account. */
|
||||||
|
@ -181,14 +185,21 @@ const EditProfile: React.FC = () => {
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const maxFields = instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number;
|
const maxFields = instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number;
|
||||||
|
|
||||||
|
const attachmentTypes = useAppSelector(
|
||||||
|
state => state.instance.configuration.getIn(['media_attachments', 'supported_mime_types']) as ImmutableList<string>,
|
||||||
|
)?.filter(type => type.startsWith('image/')).toArray().join(',');
|
||||||
|
|
||||||
const [isLoading, setLoading] = useState(false);
|
const [isLoading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<AccountCredentials>({});
|
const [data, setData] = useState<AccountCredentials>({});
|
||||||
const [muteStrangers, setMuteStrangers] = useState(false);
|
const [muteStrangers, setMuteStrangers] = useState(false);
|
||||||
|
|
||||||
|
const avatar = useImageField({ maxPixels: 400 * 400, preview: nonDefaultAvatar(account?.avatar) });
|
||||||
|
const header = useImageField({ maxPixels: 1920 * 1080, preview: nonDefaultHeader(account?.header) });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (account) {
|
if (account) {
|
||||||
const credentials = accountToCredentials(account);
|
const credentials = accountToCredentials(account);
|
||||||
const strangerNotifications = account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true;
|
const strangerNotifications = account.pleroma?.notification_settings?.block_from_strangers === true;
|
||||||
setData(credentials);
|
setData(credentials);
|
||||||
setMuteStrangers(strangerNotifications);
|
setMuteStrangers(strangerNotifications);
|
||||||
}
|
}
|
||||||
|
@ -206,6 +217,8 @@ const EditProfile: React.FC = () => {
|
||||||
|
|
||||||
const params = { ...data };
|
const params = { ...data };
|
||||||
if (params.fields_attributes?.length === 0) params.fields_attributes = [{ name: '', value: '' }];
|
if (params.fields_attributes?.length === 0) params.fields_attributes = [{ name: '', value: '' }];
|
||||||
|
if (header.file !== undefined) params.header = header.file || '';
|
||||||
|
if (avatar.file !== undefined) params.avatar = avatar.file || '';
|
||||||
|
|
||||||
promises.push(dispatch(patchMe(params, true)));
|
promises.push(dispatch(patchMe(params, true)));
|
||||||
|
|
||||||
|
@ -259,20 +272,6 @@ const EditProfile: React.FC = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (
|
|
||||||
name: keyof AccountCredentials,
|
|
||||||
maxPixels: number,
|
|
||||||
): React.ChangeEventHandler<HTMLInputElement> => {
|
|
||||||
return e => {
|
|
||||||
const f = e.target.files?.item(0);
|
|
||||||
if (!f) return;
|
|
||||||
|
|
||||||
resizeImage(f, maxPixels).then(file => {
|
|
||||||
updateData(name, file);
|
|
||||||
}).catch(console.error);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFieldsChange = (fields: AccountCredentialsField[]) => {
|
const handleFieldsChange = (fields: AccountCredentialsField[]) => {
|
||||||
updateData('fields_attributes', fields);
|
updateData('fields_attributes', fields);
|
||||||
};
|
};
|
||||||
|
@ -290,48 +289,12 @@ const EditProfile: React.FC = () => {
|
||||||
updateData('fields_attributes', fields);
|
updateData('fields_attributes', fields);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Memoized avatar preview URL. */
|
|
||||||
const avatarUrl = useMemo(() => {
|
|
||||||
return data.avatar ? URL.createObjectURL(data.avatar) : account?.avatar;
|
|
||||||
}, [data.avatar, account?.avatar]);
|
|
||||||
|
|
||||||
/** Memoized header preview URL. */
|
|
||||||
const headerUrl = useMemo(() => {
|
|
||||||
return data.header ? URL.createObjectURL(data.header) : account?.header;
|
|
||||||
}, [data.header, account?.header]);
|
|
||||||
|
|
||||||
/** Preview account data. */
|
|
||||||
const previewAccount = useMemo(() => {
|
|
||||||
return accountSchema.parse({
|
|
||||||
id: '1',
|
|
||||||
...account,
|
|
||||||
...data,
|
|
||||||
avatar: avatarUrl,
|
|
||||||
header: headerUrl,
|
|
||||||
});
|
|
||||||
}, [account?.id, data.display_name, avatarUrl, headerUrl]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column label={intl.formatMessage(messages.header)}>
|
<Column label={intl.formatMessage(messages.header)}>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
<div className='relative mb-12 flex'>
|
||||||
<ProfilePreview account={previewAccount} />
|
<HeaderPicker accept={attachmentTypes} disabled={isLoading} {...header} />
|
||||||
|
<AvatarPicker className='!sm:left-6 !left-4 !translate-x-0' accept={attachmentTypes} disabled={isLoading} {...avatar} />
|
||||||
<div className='space-y-4'>
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.header_label' defaultMessage='Choose Background Picture' />}
|
|
||||||
hintText={<FormattedMessage id='edit_profile.hints.header' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '1920x1080px' }} />}
|
|
||||||
>
|
|
||||||
<FileInput onChange={handleFileChange('header', 1920 * 1080)} />
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.avatar_label' defaultMessage='Choose Profile Picture' />}
|
|
||||||
hintText={<FormattedMessage id='edit_profile.hints.avatar' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '400x400px' }} />}
|
|
||||||
>
|
|
||||||
<FileInput onChange={handleFileChange('avatar', 400 * 400)} />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormGroup
|
<FormGroup
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
@ -61,15 +62,39 @@ const SuggestionItem: React.FC<ISuggestionItem> = ({ accountId }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FeedSuggestions = () => {
|
interface IFeedSuggesetions {
|
||||||
|
statusId: string
|
||||||
|
onMoveUp?: (statusId: string, featured?: boolean) => void
|
||||||
|
onMoveDown?: (statusId: string, featured?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedSuggestions: React.FC<IFeedSuggesetions> = ({ statusId, onMoveUp, onMoveDown }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
|
const suggestedProfiles = useAppSelector((state) => state.suggestions.items);
|
||||||
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
const isLoading = useAppSelector((state) => state.suggestions.isLoading);
|
||||||
|
|
||||||
if (!isLoading && suggestedProfiles.size === 0) return null;
|
if (!isLoading && suggestedProfiles.size === 0) return null;
|
||||||
|
|
||||||
|
const handleHotkeyMoveUp = (e?: KeyboardEvent): void => {
|
||||||
|
if (onMoveUp) {
|
||||||
|
onMoveUp(statusId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyMoveDown = (e?: KeyboardEvent): void => {
|
||||||
|
if (onMoveDown) {
|
||||||
|
onMoveDown(statusId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
moveUp: handleHotkeyMoveUp,
|
||||||
|
moveDown: handleHotkeyMoveDown,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size='lg' variant='rounded' className='space-y-6'>
|
<HotKeys handlers={handlers}>
|
||||||
|
<Card size='lg' variant='rounded' className='focusable space-y-6' tabIndex={0}>
|
||||||
<HStack justifyContent='between' alignItems='center'>
|
<HStack justifyContent='between' alignItems='center'>
|
||||||
<CardTitle title={intl.formatMessage(messages.heading)} />
|
<CardTitle title={intl.formatMessage(messages.heading)} />
|
||||||
|
|
||||||
|
@ -89,6 +114,7 @@ const FeedSuggestions = () => {
|
||||||
</HStack>
|
</HStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
</HotKeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -8,8 +8,9 @@ import { useImageField, useTextField } from 'soapbox/hooks/forms';
|
||||||
import toast from 'soapbox/toast';
|
import toast from 'soapbox/toast';
|
||||||
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
import { isDefaultAvatar, isDefaultHeader } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
import AvatarPicker from './components/group-avatar-picker';
|
import AvatarPicker from '../edit-profile/components/avatar-picker';
|
||||||
import HeaderPicker from './components/group-header-picker';
|
import HeaderPicker from '../edit-profile/components/header-picker';
|
||||||
|
|
||||||
import GroupTagsField from './components/group-tags-field';
|
import GroupTagsField from './components/group-tags-field';
|
||||||
|
|
||||||
import type { List as ImmutableList } from 'immutable';
|
import type { List as ImmutableList } from 'immutable';
|
||||||
|
@ -60,8 +61,8 @@ const EditGroup: React.FC<IEditGroup> = ({ params: { groupId } }) => {
|
||||||
await updateGroup({
|
await updateGroup({
|
||||||
display_name: displayName.value,
|
display_name: displayName.value,
|
||||||
note: note.value,
|
note: note.value,
|
||||||
avatar: avatar.file,
|
avatar: avatar.file === null ? '' : avatar.file,
|
||||||
header: header.file,
|
header: header.file === null ? '' : header.file,
|
||||||
tags,
|
tags,
|
||||||
}, {
|
}, {
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
|
|
@ -122,6 +122,9 @@ const Thread = (props: IThread) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let initialTopMostItemIndex = ancestorsIds.size;
|
||||||
|
if (!useWindowScroll && initialTopMostItemIndex !== 0) initialTopMostItemIndex = ancestorsIds.size + 1;
|
||||||
|
|
||||||
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
const [showMedia, setShowMedia] = useState<boolean>(status?.visibility === 'self' ? false : defaultMediaVisibility(status, displayMedia));
|
||||||
|
|
||||||
const node = useRef<HTMLDivElement>(null);
|
const node = useRef<HTMLDivElement>(null);
|
||||||
|
@ -263,15 +266,12 @@ const Thread = (props: IThread) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _selectChild = (index: number) => {
|
const _selectChild = (index: number) => {
|
||||||
|
if (!useWindowScroll) index = index + 1;
|
||||||
scroller.current?.scrollIntoView({
|
scroller.current?.scrollIntoView({
|
||||||
index,
|
index,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
done: () => {
|
done: () => {
|
||||||
const element = document.querySelector<HTMLDivElement>(`#thread [data-index="${index}"] .focusable`);
|
node.current?.querySelector<HTMLDivElement>(`[data-index="${index}"] .focusable`)?.focus();
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -410,7 +410,7 @@ const Thread = (props: IThread) => {
|
||||||
|
|
||||||
if (!useWindowScroll) {
|
if (!useWindowScroll) {
|
||||||
// Add padding to the top of the Thread (for Media Modal)
|
// Add padding to the top of the Thread (for Media Modal)
|
||||||
children.push(<div className='h-4' />);
|
children.push(<div key='padding' className='h-4' />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAncestors) {
|
if (hasAncestors) {
|
||||||
|
@ -447,7 +447,7 @@ const Thread = (props: IThread) => {
|
||||||
hasMore={!!next}
|
hasMore={!!next}
|
||||||
onLoadMore={handleLoadMore}
|
onLoadMore={handleLoadMore}
|
||||||
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
placeholderComponent={() => <PlaceholderStatus variant='slim' />}
|
||||||
initialTopMostItemIndex={ancestorsIds.size}
|
initialTopMostItemIndex={initialTopMostItemIndex}
|
||||||
useWindowScroll={useWindowScroll}
|
useWindowScroll={useWindowScroll}
|
||||||
itemClassName={itemClassName}
|
itemClassName={itemClassName}
|
||||||
className={
|
className={
|
||||||
|
|
|
@ -48,6 +48,9 @@ const LinkFooter: React.FC = (): JSX.Element => {
|
||||||
{(features.filters || features.filtersV2) && (
|
{(features.filters || features.filtersV2) && (
|
||||||
<FooterLink to='/filters'><FormattedMessage id='navigation_bar.filters' defaultMessage='Filters' /></FooterLink>
|
<FooterLink to='/filters'><FormattedMessage id='navigation_bar.filters' defaultMessage='Filters' /></FooterLink>
|
||||||
)}
|
)}
|
||||||
|
{features.followedHashtagsList && (
|
||||||
|
<FooterLink to='/followed_tags'><FormattedMessage id='navigation_bar.followed_tags' defaultMessage='Followed hashtags' /></FooterLink>
|
||||||
|
)}
|
||||||
{features.federating && (
|
{features.federating && (
|
||||||
<FooterLink to='/domain_blocks'><FormattedMessage id='navigation_bar.domain_blocks' defaultMessage='Domain blocks' /></FooterLink>
|
<FooterLink to='/domain_blocks'><FormattedMessage id='navigation_bar.domain_blocks' defaultMessage='Domain blocks' /></FooterLink>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { CreateGroupParams, useGroupValidation } from 'soapbox/api/hooks';
|
import { CreateGroupParams, useGroupValidation } from 'soapbox/api/hooks';
|
||||||
import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui';
|
import { Form, FormGroup, Input, Textarea } from 'soapbox/components/ui';
|
||||||
import AvatarPicker from 'soapbox/features/group/components/group-avatar-picker';
|
import AvatarPicker from 'soapbox/features/edit-profile/components/avatar-picker';
|
||||||
import HeaderPicker from 'soapbox/features/group/components/group-header-picker';
|
import HeaderPicker from 'soapbox/features/edit-profile/components/header-picker';
|
||||||
import GroupTagsField from 'soapbox/features/group/components/group-tags-field';
|
import GroupTagsField from 'soapbox/features/group/components/group-tags-field';
|
||||||
import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
|
import { useAppSelector, useDebounce, useInstance } from 'soapbox/hooks';
|
||||||
import { usePreview } from 'soapbox/hooks/forms';
|
import { usePreview } from 'soapbox/hooks/forms';
|
||||||
|
@ -66,6 +66,8 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageClear = (property: keyof CreateGroupParams) => () => onChange({ [property]: undefined });
|
||||||
|
|
||||||
const handleTagsChange = (tags: string[]) => {
|
const handleTagsChange = (tags: string[]) => {
|
||||||
onChange({
|
onChange({
|
||||||
...params,
|
...params,
|
||||||
|
@ -92,7 +94,7 @@ const DetailsStep: React.FC<IDetailsStep> = ({ params, onChange }) => {
|
||||||
return (
|
return (
|
||||||
<Form>
|
<Form>
|
||||||
<div className='relative mb-12 flex'>
|
<div className='relative mb-12 flex'>
|
||||||
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleImageChange('header', 1920 * 1080)} />
|
<HeaderPicker src={headerSrc} accept={attachmentTypes} onChange={handleImageChange('header', 1920 * 1080)} onClear={handleImageClear('header')} />
|
||||||
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleImageChange('avatar', 400 * 400)} />
|
<AvatarPicker src={avatarSrc} accept={attachmentTypes} onChange={handleImageChange('avatar', 400 * 400)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -2,13 +2,11 @@
|
||||||
|
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { HotKeys } from 'react-hotkeys';
|
|
||||||
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
import { Switch, useHistory, useLocation, Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
import { fetchFollowRequests } from 'soapbox/actions/accounts';
|
||||||
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
import { fetchReports, fetchUsers, fetchConfig } from 'soapbox/actions/admin';
|
||||||
import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
import { fetchAnnouncements } from 'soapbox/actions/announcements';
|
||||||
import { resetCompose } from 'soapbox/actions/compose';
|
|
||||||
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis';
|
||||||
import { fetchFilters } from 'soapbox/actions/filters';
|
import { fetchFilters } from 'soapbox/actions/filters';
|
||||||
import { fetchMarker } from 'soapbox/actions/markers';
|
import { fetchMarker } from 'soapbox/actions/markers';
|
||||||
|
@ -135,7 +133,9 @@ import {
|
||||||
GroupMembershipRequests,
|
GroupMembershipRequests,
|
||||||
Announcements,
|
Announcements,
|
||||||
EditGroup,
|
EditGroup,
|
||||||
|
FollowedTags,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
|
import GlobalHotkeys from './util/global-hotkeys';
|
||||||
import { WrappedRoute } from './util/react-router-helpers';
|
import { WrappedRoute } from './util/react-router-helpers';
|
||||||
|
|
||||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
// Dummy import, to make sure that <Status /> ends up in the application bundle.
|
||||||
|
@ -154,34 +154,6 @@ const GroupMembershipRequestsSlug = withHoc(GroupMembershipRequests as any, Grou
|
||||||
|
|
||||||
const EmptyPage = HomePage;
|
const EmptyPage = HomePage;
|
||||||
|
|
||||||
const keyMap = {
|
|
||||||
help: '?',
|
|
||||||
new: 'n',
|
|
||||||
search: ['s', '/'],
|
|
||||||
forceNew: 'option+n',
|
|
||||||
reply: 'r',
|
|
||||||
favourite: 'f',
|
|
||||||
react: 'e',
|
|
||||||
boost: 'b',
|
|
||||||
mention: 'm',
|
|
||||||
open: ['enter', 'o'],
|
|
||||||
openProfile: 'p',
|
|
||||||
moveDown: ['down', 'j'],
|
|
||||||
moveUp: ['up', 'k'],
|
|
||||||
back: 'backspace',
|
|
||||||
goToHome: 'g h',
|
|
||||||
goToNotifications: 'g n',
|
|
||||||
goToFavourites: 'g f',
|
|
||||||
goToPinned: 'g p',
|
|
||||||
goToProfile: 'g u',
|
|
||||||
goToBlocked: 'g b',
|
|
||||||
goToMuted: 'g m',
|
|
||||||
goToRequests: 'g r',
|
|
||||||
toggleHidden: 'x',
|
|
||||||
toggleSensitive: 'h',
|
|
||||||
openMedia: 'a',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ISwitchingColumnsArea {
|
interface ISwitchingColumnsArea {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
@ -293,6 +265,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
|
||||||
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/new' page={DefaultPage} component={EditFilter} content={children} />}
|
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/new' page={DefaultPage} component={EditFilter} content={children} />}
|
||||||
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/:id' page={DefaultPage} component={EditFilter} content={children} />}
|
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters/:id' page={DefaultPage} component={EditFilter} content={children} />}
|
||||||
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
|
{(features.filters || features.filtersV2) && <WrappedRoute path='/filters' page={DefaultPage} component={Filters} content={children} />}
|
||||||
|
{(features.followedHashtagsList) && <WrappedRoute path='/followed_tags' page={DefaultPage} component={FollowedTags} content={children} />}
|
||||||
<WrappedRoute path='/@:username' publicRoute exact component={AccountTimeline} page={ProfilePage} content={children} />
|
<WrappedRoute path='/@:username' publicRoute exact component={AccountTimeline} page={ProfilePage} content={children} />
|
||||||
<WrappedRoute path='/@:username/with_replies' publicRoute={!authenticatedProfile} component={AccountTimeline} page={ProfilePage} content={children} componentParams={{ withReplies: true }} />
|
<WrappedRoute path='/@:username/with_replies' publicRoute={!authenticatedProfile} component={AccountTimeline} page={ProfilePage} content={children} componentParams={{ withReplies: true }} />
|
||||||
<WrappedRoute path='/@:username/followers' publicRoute={!authenticatedProfile} component={Followers} page={ProfilePage} content={children} />
|
<WrappedRoute path='/@:username/followers' publicRoute={!authenticatedProfile} component={Followers} page={ProfilePage} content={children} />
|
||||||
|
@ -396,7 +369,6 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
const userStream = useRef<any>(null);
|
const userStream = useRef<any>(null);
|
||||||
const nostrStream = useRef<any>(null);
|
const nostrStream = useRef<any>(null);
|
||||||
const node = useRef<HTMLDivElement | null>(null);
|
const node = useRef<HTMLDivElement | null>(null);
|
||||||
const hotkeys = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const me = useAppSelector(state => state.me);
|
const me = useAppSelector(state => state.me);
|
||||||
const { account } = useOwnAccount();
|
const { account } = useOwnAccount();
|
||||||
|
@ -527,91 +499,6 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
}
|
}
|
||||||
}, [pendingPolicy, !!account]);
|
}, [pendingPolicy, !!account]);
|
||||||
|
|
||||||
const handleHotkeyNew = (e?: KeyboardEvent) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (!node.current) return;
|
|
||||||
|
|
||||||
const element = node.current.querySelector('textarea#compose-textarea') as HTMLTextAreaElement;
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeySearch = (e?: KeyboardEvent) => {
|
|
||||||
e?.preventDefault();
|
|
||||||
if (!node.current) return;
|
|
||||||
|
|
||||||
const element = node.current.querySelector('input#search') as HTMLInputElement;
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
element.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyForceNew = (e?: KeyboardEvent) => {
|
|
||||||
handleHotkeyNew(e);
|
|
||||||
dispatch(resetCompose());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyBack = () => {
|
|
||||||
if (window.history && window.history.length === 1) {
|
|
||||||
history.push('/');
|
|
||||||
} else {
|
|
||||||
history.goBack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setHotkeysRef: React.LegacyRef<HotKeys> = (c: any) => {
|
|
||||||
hotkeys.current = c;
|
|
||||||
|
|
||||||
if (!me || !hotkeys.current) return;
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => {
|
|
||||||
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyToggleHelp = () => {
|
|
||||||
dispatch(openModal('HOTKEYS'));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToHome = () => {
|
|
||||||
history.push('/');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToNotifications = () => {
|
|
||||||
history.push('/notifications');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToFavourites = () => {
|
|
||||||
if (!account) return;
|
|
||||||
history.push(`/@${account.username}/favorites`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToPinned = () => {
|
|
||||||
if (!account) return;
|
|
||||||
history.push(`/@${account.username}/pins`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToProfile = () => {
|
|
||||||
if (!account) return;
|
|
||||||
history.push(`/@${account.username}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToBlocked = () => {
|
|
||||||
history.push('/blocks');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToMuted = () => {
|
|
||||||
history.push('/mutes');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHotkeyGoToRequests = () => {
|
|
||||||
history.push('/follow_requests');
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldHideFAB = (): boolean => {
|
const shouldHideFAB = (): boolean => {
|
||||||
const path = location.pathname;
|
const path = location.pathname;
|
||||||
return Boolean(path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/));
|
return Boolean(path.match(/^\/posts\/|^\/search|^\/getting-started|^\/chats/));
|
||||||
|
@ -620,30 +507,12 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
// Wait for login to succeed or fail
|
// Wait for login to succeed or fail
|
||||||
if (me === null) return null;
|
if (me === null) return null;
|
||||||
|
|
||||||
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
|
|
||||||
|
|
||||||
const handlers: HotkeyHandlers = {
|
|
||||||
help: handleHotkeyToggleHelp,
|
|
||||||
new: handleHotkeyNew,
|
|
||||||
search: handleHotkeySearch,
|
|
||||||
forceNew: handleHotkeyForceNew,
|
|
||||||
back: handleHotkeyBack,
|
|
||||||
goToHome: handleHotkeyGoToHome,
|
|
||||||
goToNotifications: handleHotkeyGoToNotifications,
|
|
||||||
goToFavourites: handleHotkeyGoToFavourites,
|
|
||||||
goToPinned: handleHotkeyGoToPinned,
|
|
||||||
goToProfile: handleHotkeyGoToProfile,
|
|
||||||
goToBlocked: handleHotkeyGoToBlocked,
|
|
||||||
goToMuted: handleHotkeyGoToMuted,
|
|
||||||
goToRequests: handleHotkeyGoToRequests,
|
|
||||||
};
|
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
pointerEvents: dropdownMenuIsOpen ? 'none' : undefined,
|
pointerEvents: dropdownMenuIsOpen ? 'none' : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
|
<GlobalHotkeys node={node}>
|
||||||
<div ref={node} style={style}>
|
<div ref={node} style={style}>
|
||||||
<div
|
<div
|
||||||
className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', {
|
className={clsx('pointer-events-none fixed z-[90] h-screen w-screen transition', {
|
||||||
|
@ -698,7 +567,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</GlobalHotkeys>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -637,3 +637,7 @@ export function Announcements() {
|
||||||
export function EditAnnouncementModal() {
|
export function EditAnnouncementModal() {
|
||||||
return import(/* webpackChunkName: "features/admin/announcements" */'../components/modals/edit-announcement-modal');
|
return import(/* webpackChunkName: "features/admin/announcements" */'../components/modals/edit-announcement-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FollowedTags() {
|
||||||
|
return import(/* webpackChunkName: "features/followed-tags" */'../../followed-tags');
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,160 @@
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { resetCompose } from 'soapbox/actions/compose';
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { useAppSelector, useAppDispatch, useOwnAccount } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const keyMap = {
|
||||||
|
help: '?',
|
||||||
|
new: 'n',
|
||||||
|
search: ['s', '/'],
|
||||||
|
forceNew: 'option+n',
|
||||||
|
reply: 'r',
|
||||||
|
favourite: 'f',
|
||||||
|
react: 'e',
|
||||||
|
boost: 'b',
|
||||||
|
mention: 'm',
|
||||||
|
open: ['enter', 'o'],
|
||||||
|
openProfile: 'p',
|
||||||
|
moveDown: ['down', 'j'],
|
||||||
|
moveUp: ['up', 'k'],
|
||||||
|
back: 'backspace',
|
||||||
|
goToHome: 'g h',
|
||||||
|
goToNotifications: 'g n',
|
||||||
|
goToFavourites: 'g f',
|
||||||
|
goToPinned: 'g p',
|
||||||
|
goToProfile: 'g u',
|
||||||
|
goToBlocked: 'g b',
|
||||||
|
goToMuted: 'g m',
|
||||||
|
goToRequests: 'g r',
|
||||||
|
toggleHidden: 'x',
|
||||||
|
toggleSensitive: 'h',
|
||||||
|
openMedia: 'a',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IGlobalHotkeys {
|
||||||
|
children: React.ReactNode
|
||||||
|
node: React.MutableRefObject<HTMLDivElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalHotkeys: React.FC<IGlobalHotkeys> = ({ children, node }) => {
|
||||||
|
const hotkeys = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
const { account } = useOwnAccount();
|
||||||
|
|
||||||
|
const handleHotkeyNew = (e?: KeyboardEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!node.current) return;
|
||||||
|
|
||||||
|
const element = node.current.querySelector('textarea#compose-textarea') as HTMLTextAreaElement;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeySearch = (e?: KeyboardEvent) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
if (!node.current) return;
|
||||||
|
|
||||||
|
const element = node.current.querySelector('input#search') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyForceNew = (e?: KeyboardEvent) => {
|
||||||
|
handleHotkeyNew(e);
|
||||||
|
dispatch(resetCompose());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyBack = () => {
|
||||||
|
if (window.history && window.history.length === 1) {
|
||||||
|
history.push('/');
|
||||||
|
} else {
|
||||||
|
history.goBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setHotkeysRef: React.LegacyRef<HotKeys> = (c: any) => {
|
||||||
|
hotkeys.current = c;
|
||||||
|
|
||||||
|
if (!me || !hotkeys.current) return;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
hotkeys.current.__mousetrap__.stopCallback = (_e, element) => {
|
||||||
|
return ['TEXTAREA', 'SELECT', 'INPUT', 'EM-EMOJI-PICKER'].includes(element.tagName);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyToggleHelp = () => {
|
||||||
|
dispatch(openModal('HOTKEYS'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToHome = () => {
|
||||||
|
history.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToNotifications = () => {
|
||||||
|
history.push('/notifications');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToFavourites = () => {
|
||||||
|
if (!account) return;
|
||||||
|
history.push(`/@${account.username}/favorites`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToPinned = () => {
|
||||||
|
if (!account) return;
|
||||||
|
history.push(`/@${account.username}/pins`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToProfile = () => {
|
||||||
|
if (!account) return;
|
||||||
|
history.push(`/@${account.username}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToBlocked = () => {
|
||||||
|
history.push('/blocks');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToMuted = () => {
|
||||||
|
history.push('/mutes');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHotkeyGoToRequests = () => {
|
||||||
|
history.push('/follow_requests');
|
||||||
|
};
|
||||||
|
|
||||||
|
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
|
||||||
|
|
||||||
|
const handlers: HotkeyHandlers = {
|
||||||
|
help: handleHotkeyToggleHelp,
|
||||||
|
new: handleHotkeyNew,
|
||||||
|
search: handleHotkeySearch,
|
||||||
|
forceNew: handleHotkeyForceNew,
|
||||||
|
back: handleHotkeyBack,
|
||||||
|
goToHome: handleHotkeyGoToHome,
|
||||||
|
goToNotifications: handleHotkeyGoToNotifications,
|
||||||
|
goToFavourites: handleHotkeyGoToFavourites,
|
||||||
|
goToPinned: handleHotkeyGoToPinned,
|
||||||
|
goToProfile: handleHotkeyGoToProfile,
|
||||||
|
goToBlocked: handleHotkeyGoToBlocked,
|
||||||
|
goToMuted: handleHotkeyGoToMuted,
|
||||||
|
goToRequests: handleHotkeyGoToRequests,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HotKeys keyMap={keyMap} handlers={me ? handlers : undefined} ref={setHotkeysRef} attach={window} focused>
|
||||||
|
{children}
|
||||||
|
</HotKeys>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GlobalHotkeys;
|
|
@ -13,8 +13,9 @@ interface UseImageFieldOpts {
|
||||||
|
|
||||||
/** Returns props for `<input type="file">`, and optionally resizes the file. */
|
/** Returns props for `<input type="file">`, and optionally resizes the file. */
|
||||||
function useImageField(opts: UseImageFieldOpts = {}) {
|
function useImageField(opts: UseImageFieldOpts = {}) {
|
||||||
const [file, setFile] = useState<File>();
|
const [file, setFile] = useState<File | null>();
|
||||||
const src = usePreview(file) || opts.preview;
|
const src = usePreview(file) || (file === null ? undefined : opts.preview);
|
||||||
|
console.log(file, src);
|
||||||
|
|
||||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = async ({ target: { files } }) => {
|
const onChange: React.ChangeEventHandler<HTMLInputElement> = async ({ target: { files } }) => {
|
||||||
const file = files?.item(0);
|
const file = files?.item(0);
|
||||||
|
@ -27,10 +28,13 @@ function useImageField(opts: UseImageFieldOpts = {}) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClear = () => setFile(null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
src,
|
src,
|
||||||
file,
|
file,
|
||||||
onChange,
|
onChange,
|
||||||
|
onClear,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -350,6 +350,7 @@
|
||||||
"column.filters.title": "Title",
|
"column.filters.title": "Title",
|
||||||
"column.filters.whole_word": "Whole word",
|
"column.filters.whole_word": "Whole word",
|
||||||
"column.follow_requests": "Follow requests",
|
"column.follow_requests": "Follow requests",
|
||||||
|
"column.followed_tags": "Followed hashtags",
|
||||||
"column.followers": "Followers",
|
"column.followers": "Followers",
|
||||||
"column.following": "Following",
|
"column.following": "Following",
|
||||||
"column.group_blocked_members": "Banned Members",
|
"column.group_blocked_members": "Banned Members",
|
||||||
|
@ -579,7 +580,6 @@
|
||||||
"edit_password.header": "Change Password",
|
"edit_password.header": "Change Password",
|
||||||
"edit_profile.error": "Profile update failed",
|
"edit_profile.error": "Profile update failed",
|
||||||
"edit_profile.fields.accepts_email_list_label": "Subscribe to newsletter",
|
"edit_profile.fields.accepts_email_list_label": "Subscribe to newsletter",
|
||||||
"edit_profile.fields.avatar_label": "Choose Profile Picture",
|
|
||||||
"edit_profile.fields.bio_label": "Bio",
|
"edit_profile.fields.bio_label": "Bio",
|
||||||
"edit_profile.fields.bio_placeholder": "Tell us about yourself.",
|
"edit_profile.fields.bio_placeholder": "Tell us about yourself.",
|
||||||
"edit_profile.fields.birthday_label": "Birthday",
|
"edit_profile.fields.birthday_label": "Birthday",
|
||||||
|
@ -588,7 +588,6 @@
|
||||||
"edit_profile.fields.discoverable_label": "Allow account discovery",
|
"edit_profile.fields.discoverable_label": "Allow account discovery",
|
||||||
"edit_profile.fields.display_name_label": "Display name",
|
"edit_profile.fields.display_name_label": "Display name",
|
||||||
"edit_profile.fields.display_name_placeholder": "Name",
|
"edit_profile.fields.display_name_placeholder": "Name",
|
||||||
"edit_profile.fields.header_label": "Choose Background Picture",
|
|
||||||
"edit_profile.fields.hide_network_label": "Hide network",
|
"edit_profile.fields.hide_network_label": "Hide network",
|
||||||
"edit_profile.fields.location_label": "Location",
|
"edit_profile.fields.location_label": "Location",
|
||||||
"edit_profile.fields.location_placeholder": "Location",
|
"edit_profile.fields.location_placeholder": "Location",
|
||||||
|
@ -601,10 +600,8 @@
|
||||||
"edit_profile.fields.website_placeholder": "Display a Link",
|
"edit_profile.fields.website_placeholder": "Display a Link",
|
||||||
"edit_profile.header": "Edit Profile",
|
"edit_profile.header": "Edit Profile",
|
||||||
"edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.",
|
"edit_profile.hints.accepts_email_list": "Opt-in to news and marketing updates.",
|
||||||
"edit_profile.hints.avatar": "PNG, GIF or JPG. Will be downscaled to {size}",
|
|
||||||
"edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored",
|
"edit_profile.hints.bot": "This account mainly performs automated actions and might not be monitored",
|
||||||
"edit_profile.hints.discoverable": "Display account in profile directory and allow indexing by external services",
|
"edit_profile.hints.discoverable": "Display account in profile directory and allow indexing by external services",
|
||||||
"edit_profile.hints.header": "PNG, GIF or JPG. Will be downscaled to {size}",
|
|
||||||
"edit_profile.hints.hide_network": "Who you follow and who follows you will not be shown on your profile",
|
"edit_profile.hints.hide_network": "Who you follow and who follows you will not be shown on your profile",
|
||||||
"edit_profile.hints.locked": "Requires you to manually approve followers",
|
"edit_profile.hints.locked": "Requires you to manually approve followers",
|
||||||
"edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# custom field} other {# custom fields}} displayed on your profile.",
|
"edit_profile.hints.meta_fields": "You can have up to {count, plural, one {# custom field} other {# custom fields}} displayed on your profile.",
|
||||||
|
@ -676,6 +673,7 @@
|
||||||
"empty_column.filters": "You haven't created any muted words yet.",
|
"empty_column.filters": "You haven't created any muted words yet.",
|
||||||
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
|
||||||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||||
|
"empty_column.followed_tags": "You haven't followed any hashtag yet.",
|
||||||
"empty_column.group": "There are no posts in this group yet.",
|
"empty_column.group": "There are no posts in this group yet.",
|
||||||
"empty_column.group_blocks": "The group hasn't banned any users yet.",
|
"empty_column.group_blocks": "The group hasn't banned any users yet.",
|
||||||
"empty_column.group_membership_requests": "There are no pending membership requests for this group.",
|
"empty_column.group_membership_requests": "There are no pending membership requests for this group.",
|
||||||
|
@ -836,7 +834,9 @@
|
||||||
"group.unmute.long_label": "Unmute Group",
|
"group.unmute.long_label": "Unmute Group",
|
||||||
"group.unmute.success": "Unmuted the group",
|
"group.unmute.success": "Unmuted the group",
|
||||||
"group.update.success": "Group successfully saved",
|
"group.update.success": "Group successfully saved",
|
||||||
|
"group.upload_avatar": "Upload avatar",
|
||||||
"group.upload_banner": "Upload photo",
|
"group.upload_banner": "Upload photo",
|
||||||
|
"group.upload_banner.title": "Upload background picture",
|
||||||
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
"groups.discover.popular.empty": "Unable to fetch popular groups at this time. Please check back later.",
|
||||||
"groups.discover.popular.show_more": "Show More",
|
"groups.discover.popular.show_more": "Show More",
|
||||||
"groups.discover.popular.title": "Popular Groups",
|
"groups.discover.popular.title": "Popular Groups",
|
||||||
|
@ -1083,6 +1083,7 @@
|
||||||
"navigation_bar.favourites": "Likes",
|
"navigation_bar.favourites": "Likes",
|
||||||
"navigation_bar.filters": "Filters",
|
"navigation_bar.filters": "Filters",
|
||||||
"navigation_bar.follow_requests": "Follow requests",
|
"navigation_bar.follow_requests": "Follow requests",
|
||||||
|
"navigation_bar.followed_tags": "Followed hashtags",
|
||||||
"navigation_bar.import_data": "Import data",
|
"navigation_bar.import_data": "Import data",
|
||||||
"navigation_bar.in_reply_to": "In reply to",
|
"navigation_bar.in_reply_to": "In reply to",
|
||||||
"navigation_bar.invites": "Invites",
|
"navigation_bar.invites": "Invites",
|
||||||
|
|
|
@ -120,7 +120,7 @@ const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
||||||
<BundleContainer fetchComponent={ProfileMediaPanel}>
|
<BundleContainer fetchComponent={ProfileMediaPanel}>
|
||||||
{Component => <Component account={account} />}
|
{Component => <Component account={account} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
{account && !account.fields.length && (
|
{(account && account.fields.length > 0) && (
|
||||||
<BundleContainer fetchComponent={ProfileFieldsPanel}>
|
<BundleContainer fetchComponent={ProfileFieldsPanel}>
|
||||||
{Component => <Component account={account} />}
|
{Component => <Component account={account} />}
|
||||||
</BundleContainer>
|
</BundleContainer>
|
||||||
|
|
|
@ -29,7 +29,7 @@ import custom_emojis from './custom-emojis';
|
||||||
import domain_lists from './domain-lists';
|
import domain_lists from './domain-lists';
|
||||||
import dropdown_menu from './dropdown-menu';
|
import dropdown_menu from './dropdown-menu';
|
||||||
import filters from './filters';
|
import filters from './filters';
|
||||||
import followed_tags from './followed_tags';
|
import followed_tags from './followed-tags';
|
||||||
import group_memberships from './group-memberships';
|
import group_memberships from './group-memberships';
|
||||||
import group_relationships from './group-relationships';
|
import group_relationships from './group-relationships';
|
||||||
import groups from './groups';
|
import groups from './groups';
|
||||||
|
|
|
@ -93,7 +93,7 @@ const isValid = (notification: APIEntity) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mastodon can return status notifications with a null status
|
// Mastodon can return status notifications with a null status
|
||||||
if (['mention', 'reblog', 'favourite', 'poll', 'status'].includes(notification.type) && !notification.status.id) {
|
if (['mention', 'reblog', 'favourite', 'poll', 'status'].includes(notification.type) && !notification.getIn(['status', 'id'])) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -62,6 +62,16 @@ describe('parseVersion', () => {
|
||||||
build: 'cofe',
|
build: 'cofe',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with Mastodon nightly build', () => {
|
||||||
|
const version = '4.1.2+nightly-20230627';
|
||||||
|
expect(parseVersion(version)).toEqual({
|
||||||
|
software: 'Mastodon',
|
||||||
|
version: '4.1.2',
|
||||||
|
compatVersion: '4.1.2',
|
||||||
|
build: 'nightly-20230627',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getFeatures', () => {
|
describe('getFeatures', () => {
|
||||||
|
|
|
@ -34,9 +34,10 @@ export const isLocal = (account: Pick<Account, 'acct'>): boolean => {
|
||||||
export const isRemote = (account: Pick<Account, 'acct'>): boolean => !isLocal(account);
|
export const isRemote = (account: Pick<Account, 'acct'>): boolean => !isLocal(account);
|
||||||
|
|
||||||
/** Default header filenames from various backends */
|
/** Default header filenames from various backends */
|
||||||
const DEFAULT_HEADERS = [
|
const DEFAULT_HEADERS: string[] = [
|
||||||
'/headers/original/missing.png', // Mastodon
|
'/headers/original/missing.png', // Mastodon
|
||||||
'/images/banner.png', // Pleroma
|
'/images/banner.png', // Pleroma
|
||||||
|
require('assets/images/header-missing.png'), // header not provided by backend
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Check if the avatar is a default avatar */
|
/** Check if the avatar is a default avatar */
|
||||||
|
@ -48,6 +49,7 @@ export const isDefaultHeader = (url: string) => {
|
||||||
const DEFAULT_AVATARS = [
|
const DEFAULT_AVATARS = [
|
||||||
'/avatars/original/missing.png', // Mastodon
|
'/avatars/original/missing.png', // Mastodon
|
||||||
'/images/avi.png', // Pleroma
|
'/images/avi.png', // Pleroma
|
||||||
|
require('assets/images/avatar-missing.png'), // avatar not provided by backend
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Check if the avatar is a default avatar */
|
/** Check if the avatar is a default avatar */
|
||||||
|
|
|
@ -992,7 +992,7 @@ interface Backend {
|
||||||
|
|
||||||
/** Get information about the software from its version string */
|
/** Get information about the software from its version string */
|
||||||
export const parseVersion = (version: string): Backend => {
|
export const parseVersion = (version: string): Backend => {
|
||||||
const regex = /^([\w+.]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
const regex = /^([\w+.-]*)(?: \(compatible; ([\w]*) (.*)\))?$/;
|
||||||
const match = regex.exec(version);
|
const match = regex.exec(version);
|
||||||
|
|
||||||
const semverString = match && (match[3] || match[1]);
|
const semverString = match && (match[3] || match[1]);
|
||||||
|
|
|
@ -163,7 +163,7 @@
|
||||||
"react-sticky-box": "^2.0.0",
|
"react-sticky-box": "^2.0.0",
|
||||||
"react-swipeable-views": "^0.14.0",
|
"react-swipeable-views": "^0.14.0",
|
||||||
"react-textarea-autosize": "^8.3.4",
|
"react-textarea-autosize": "^8.3.4",
|
||||||
"react-virtuoso": "^4.0.8",
|
"react-virtuoso": "^4.3.11",
|
||||||
"redux": "^4.1.1",
|
"redux": "^4.1.1",
|
||||||
"redux-immutable": "^4.0.0",
|
"redux-immutable": "^4.0.0",
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.2.0",
|
||||||
|
|
|
@ -14866,10 +14866,10 @@ react-transition-group@^2.2.1:
|
||||||
prop-types "^15.6.2"
|
prop-types "^15.6.2"
|
||||||
react-lifecycles-compat "^3.0.4"
|
react-lifecycles-compat "^3.0.4"
|
||||||
|
|
||||||
react-virtuoso@^4.0.8:
|
react-virtuoso@^4.3.11:
|
||||||
version "4.0.8"
|
version "4.3.11"
|
||||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.0.8.tgz#6543573e5b2da8cd5808bd687655cf24d7930dfe"
|
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.3.11.tgz#ab24e707287ef1b4bb5b52f3b14795ba896e9768"
|
||||||
integrity sha512-ne9QzKajqwDT13t2nt5uktuFkyBTjRsJCdF06gdwcPVP6lrWt/VE5tkKf2OVtMqfethR8/FHuAYDOLyT5YtddQ==
|
integrity sha512-0YrCvQ5GsIKRcN34GxrzhSJGuMNI+hGxWci5cTVuPQ8QWTEsrKfCyqm7YNBMmV3pu7onG1YVUBo86CyCXdejXg==
|
||||||
|
|
||||||
react@^18.0.0:
|
react@^18.0.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
|
|
Loading…
Reference in New Issue